Compare commits

..

12 Commits

Author SHA1 Message Date
mamoodi
a820e45ec4 Release 0.32.0 2025-04-09 12:43:20 -04:00
tofarr
588b9f34be Fix scrolling (#7780) 2025-04-09 16:35:29 +00:00
Ray Myers
fb02fefaca chore - Remove unneeded dependencies from main poetry (#7772) 2025-04-09 11:24:30 -05:00
dependabot[bot]
856d5ff976 chore(deps): bump the version-all group across 1 directory with 28 updates (#7741)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-04-09 14:39:21 +00:00
sp.wack
eb4aeb3922 Fix frontend pre-commit and move unlocalized strings check to pre-commit hook (#7763)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-09 14:29:36 +00:00
Ray Myers
4b47e5215b chore - Use blacksmith docker build with caching (#7771) 2025-04-09 08:50:09 -05:00
Xingyao Wang
0087082643 Improve binary file handling and patch generation in SWE-bench evaluation (#7762)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-08 22:57:33 +00:00
Graham Neubig
e698a393b2 Add more extensive typing to openhands/core directory (#7728)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-08 17:38:44 -04:00
Ray Myers
d48e2a4cf1 chore - Remove redundant cache saving from CI (#7768) 2025-04-08 16:11:17 -05:00
Ray Myers
749a903de5 chore - Skip building Ubuntu runtime image on PR (#7765) 2025-04-08 15:02:25 -05:00
sp.wack
0a6321246a chore(frontend): Remove waitlist variant of auth modal (#7150)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-04-08 18:56:27 +00:00
Xingyao Wang
ddda30d9b7 fix(eval): iterative evaluation improvements; SWE-Bench multimodal fixes (#7739)
Co-authored-by: Juan Michelini <juan@juan.com.uy>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-09 02:44:03 +08:00
85 changed files with 2613 additions and 3011 deletions

View File

@@ -28,6 +28,28 @@ env:
RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
jobs:
define-matrix:
runs-on: blacksmith
outputs:
base_image: ${{ steps.define-base-images.outputs.base_image }}
steps:
- name: Define base images
shell: bash
id: define-base-images
run: |
# Only build nikolaik on PRs, otherwise build both nikolaik and ubuntu.
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
]')
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
echo "base_image=$json" >> "$GITHUB_OUTPUT"
# Builds the OpenHands Docker images
ghcr_build_app:
name: Build App Image
@@ -84,13 +106,10 @@ jobs:
permissions:
contents: read
packages: write
needs: define-matrix
strategy:
matrix:
base_image:
- image: 'nikolaik/python-nodejs:python3.12-nodejs22'
tag: nikolaik
- image: 'ubuntu:24.04'
tag: ubuntu
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -118,9 +137,9 @@ jobs:
with:
path: |
~/.cache/pypoetry
~/.cache/ms-playwright
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
# This is the one that saves the cache, the others set 'lookup-only: true'
restore-keys: |
${{ runner.os }}-poetry-
- name: Install poetry via pipx
@@ -132,14 +151,33 @@ jobs:
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Short SHA
run: |
echo SHORT_SHA=$(git rev-parse --short "$RELEVANT_SHA") >> $GITHUB_ENV
- name: Determine docker build params
if: github.event.pull_request.head.repo.fork != true
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
echo "DOCKER_PLATFORM=$(echo "$DOCKER_BUILD_JSON" | jq -r '.platform')" >> $GITHUB_ENV
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
- name: Build and push runtime image ${{ matrix.base_image.image }}
if: github.event.pull_request.head.repo.fork != true
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} --push -t ${{ matrix.base_image.tag }}
uses: useblacksmith/build-push-action@v1
with:
push: true
tags: ${{ env.DOCKER_TAGS }}
platforms: ${{ env.DOCKER_PLATFORM }}
build-args: ${{ env.DOCKER_BUILD_ARGS }}
context: containers/runtime
provenance: false
# Forked repos can't push to GHCR, so we need to upload the image as an artifact
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
outputs: type=docker,dest=/tmp/runtime-${{ matrix.base_image.tag }}.tar
@@ -170,9 +208,9 @@ jobs:
with:
path: |
~/.cache/pypoetry
~/.cache/ms-playwright
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
@@ -209,12 +247,12 @@ jobs:
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
needs: [ghcr_build_runtime]
needs: [ghcr_build_runtime, define-matrix]
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
fail-fast: false
matrix:
base_image: ['nikolaik', 'ubuntu']
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
@@ -225,20 +263,20 @@ jobs:
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v4
with:
name: runtime-${{ matrix.base_image }}
name: runtime-${{ matrix.base_image.tag }}
path: /tmp
- name: Load runtime image for fork
if: github.event.pull_request.head.repo.fork
run: |
docker load --input /tmp/runtime-${{ matrix.base_image }}.tar
docker load --input /tmp/runtime-${{ matrix.base_image.tag }}.tar
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.cache/ms-playwright
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
@@ -260,7 +298,7 @@ jobs:
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
# Setting RUN_AS_OPENHANDS to false means use root.
# That should mean SANDBOX_USER_ID is ignored but some tests do not check for RUN_AS_OPENHANDS.
@@ -280,10 +318,10 @@ jobs:
test_runtime_oh:
name: RT Unit Tests (openhands)
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_runtime]
needs: [ghcr_build_runtime, define-matrix]
strategy:
matrix:
base_image: [nikolaik, ubuntu]
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
@@ -294,12 +332,12 @@ jobs:
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v4
with:
name: runtime-${{ matrix.base_image }}
name: runtime-${{ matrix.base_image.tag }}
path: /tmp
- name: Load runtime image for fork
if: github.event.pull_request.head.repo.fork
run: |
docker load --input /tmp/runtime-${{ matrix.base_image }}.tar
docker load --input /tmp/runtime-${{ matrix.base_image.tag }}.tar
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
@@ -307,6 +345,7 @@ jobs:
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
@@ -328,7 +367,7 @@ jobs:
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
TEST_RUNTIME=docker \
SANDBOX_USER_ID=$(id -u) \

View File

@@ -118,7 +118,7 @@ poetry run pytest ./tests/unit/test_*.py
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.31-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.32-nikolaik`
## Develop inside Docker container

View File

@@ -185,7 +185,7 @@ test:
build-frontend:
@echo "$(YELLOW)Building frontend...$(RESET)"
@cd frontend && npm run build
@cd frontend && npm run prepare && npm run build
# Start backend
start-backend:

View File

@@ -43,17 +43,17 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-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.31
docker.all-hands.dev/all-hands-ai/openhands:0.32
```
> [!WARNING]

View File

@@ -7,15 +7,17 @@ org_name=""
push=0
load=0
tag_suffix=""
dry_run=0
# Function to display usage information
usage() {
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>]"
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry]"
echo " -i: Image name (required)"
echo " -o: Organization name"
echo " --push: Push the image"
echo " --load: Load the image"
echo " -t: Tag suffix"
echo " --dry: Don't build, only create build-args.json"
exit 1
}
@@ -27,6 +29,7 @@ while [[ $# -gt 0 ]]; do
--push) push=1; shift ;;
--load) load=1; shift ;;
-t) tag_suffix="$2"; shift 2 ;;
--dry) dry_run=1; shift ;;
*) usage ;;
esac
done
@@ -113,10 +116,13 @@ echo "Repo: $DOCKER_REPOSITORY"
echo "Base dir: $DOCKER_BASE_DIR"
args=""
full_tags=()
for tag in "${tags[@]}"; do
args+=" -t $DOCKER_REPOSITORY:$tag"
full_tags+=("$DOCKER_REPOSITORY:$tag")
done
if [[ $push -eq 1 ]]; then
args+=" --push"
args+=" --cache-to=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag,mode=max"
@@ -136,6 +142,26 @@ else
# For push or without load, build for multiple platforms
platform="linux/amd64,linux/arm64"
fi
if [[ $dry_run -eq 1 ]]; then
echo "Dry Run is enabled. Writing build config to docker-build-dry.json"
jq -n \
--argjson tags "$(printf '%s\n' "${full_tags[@]}" | jq -R . | jq -s .)" \
--arg platform "$platform" \
--arg openhands_build_version "$OPENHANDS_BUILD_VERSION" \
--arg dockerfile "$dir/Dockerfile" \
'{
tags: $tags,
platform: $platform,
build_args: [
"OPENHANDS_BUILD_VERSION=" + $openhands_build_version
],
dockerfile: $dockerfile
}' > docker-build-dry.json
exit 0
fi
echo "Building for platform(s): $platform"

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.31
docker.all-hands.dev/all-hands-ai/openhands:0.32
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.32 \
python -m openhands.core.cli
```

View File

@@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -42,7 +42,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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.32 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
```bash
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-v $WORKSPACE_BASE:/opt/workspace_base \
@@ -82,5 +82,5 @@ docker network create openhands-network
# 分離されたネットワークで OpenHands を実行
docker run # ... \
--network openhands-network \
docker.all-hands.dev/all-hands-ai/openhands:0.31
docker.all-hands.dev/all-hands-ai/openhands:0.32
```

View File

@@ -35,7 +35,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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.32 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.32 \
python -m openhands.core.main -t "escreva um script bash que imprima oi"
```

View File

@@ -58,17 +58,17 @@
A maneira mais fácil de executar o OpenHands é no Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-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.31
docker.all-hands.dev/all-hands-ai/openhands:0.32
```
Você encontrará o OpenHands em execução em http://localhost:3000!

View File

@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

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

View File

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

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.31
docker.all-hands.dev/all-hands-ai/openhands:0.32
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。

View File

@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.32 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.32 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.32-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.32-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.31
docker.all-hands.dev/all-hands-ai/openhands:0.32
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -0,0 +1,52 @@
"""
Utilities for handling binary files and patch generation in SWE-bench evaluation.
"""
def remove_binary_diffs(patch_text):
"""
Remove binary file diffs from a git patch.
Args:
patch_text (str): The git patch text
Returns:
str: The cleaned patch text with binary diffs removed
"""
lines = patch_text.splitlines()
cleaned_lines = []
block = []
is_binary_block = False
for line in lines:
if line.startswith('diff --git '):
if block and not is_binary_block:
cleaned_lines.extend(block)
block = [line]
is_binary_block = False
elif 'Binary files' in line:
is_binary_block = True
block.append(line)
else:
block.append(line)
if block and not is_binary_block:
cleaned_lines.extend(block)
return '\n'.join(cleaned_lines)
def remove_binary_files_from_git():
"""
Generate a bash command to remove binary files from git staging.
Returns:
str: A bash command that removes binary files from git staging
"""
return """
for file in $(git status --porcelain | grep -E "^(M| M|\\?\\?|A| A)" | cut -c4-); do
if [ -f "$file" ] && (file "$file" | grep -q "executable" || git check-attr binary "$file" | grep -q "binary: set"); then
git rm -f "$file" 2>/dev/null || rm -f "$file"
echo "Removed: $file"
fi
done
""".strip()

View File

@@ -28,7 +28,7 @@ def get_resource_mapping(dataset_name: str) -> dict[str, float]:
with open(file_path, 'r') as f:
_global_resource_mapping[dataset_name] = json.load(f)
logger.info(f'Loaded resource mapping for {dataset_name}')
logger.debug(f'Loaded resource mapping for {dataset_name}')
return _global_resource_mapping[dataset_name]

View File

@@ -10,6 +10,10 @@ import toml
from datasets import load_dataset
import openhands.agenthub
from evaluation.benchmarks.swe_bench.binary_patch_utils import (
remove_binary_diffs,
remove_binary_files_from_git,
)
from evaluation.benchmarks.swe_bench.resource.mapping import (
get_instance_resource_factor,
)
@@ -38,8 +42,12 @@ from openhands.core.config import (
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.critic import AgentFinishedCritic
from openhands.events.action import CmdRunAction, MessageAction
from openhands.events.observation import CmdOutputObservation, ErrorObservation
from openhands.events.action import CmdRunAction, FileReadAction, MessageAction
from openhands.events.observation import (
CmdOutputObservation,
ErrorObservation,
FileReadObservation,
)
from openhands.events.serialization.event import event_from_dict, event_to_dict
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import call_async_from_sync
@@ -121,7 +129,7 @@ Be thorough in your exploration, testing, and reasoning. It's fine if your think
)
if 'image_assets' in instance:
assets = instance['image_assets']
assets = json.loads(instance['image_assets'])
assert (
'problem_statement' in assets
), 'problem_statement is required in image_assets'
@@ -146,8 +154,8 @@ def get_instance_docker_image(
# swebench/sweb.eval.x86_64.django_1776_django-11333:v1
docker_image_prefix = 'docker.io/swebench/'
repo, name = instance_id.split('__')
image_name = f'swebench/sweb.eval.x86_64.{repo}_1776_{name}:latest'
logger.info(f'Using official SWE-Bench image: {image_name}')
image_name = f'swebench/sweb.eval.x86_64.{repo}_1776_{name}:latest'.lower()
logger.debug(f'Using official SWE-Bench image: {image_name}')
return image_name
else:
# OpenHands version of the image
@@ -164,10 +172,7 @@ def get_config(
metadata: EvalMetadata,
) -> AppConfig:
# We use a different instance image for the each instance of swe-bench eval
use_swebench_official_image = bool(
('verified' in metadata.dataset.lower() or 'lite' in metadata.dataset.lower())
and 'swe-gym' not in metadata.dataset.lower()
)
use_swebench_official_image = 'swe-gym' not in metadata.dataset.lower()
base_container_image = get_instance_docker_image(
instance['instance_id'],
swebench_official_image=use_swebench_official_image,
@@ -229,16 +234,17 @@ def initialize_runtime(
workspace_dir_name = _get_swebench_workspace_dir_name(instance)
obs: CmdOutputObservation
# Set instance id
# Set instance id and git configuration
action = CmdRunAction(
command=f"""echo 'export SWE_INSTANCE_ID={instance['instance_id']}' >> ~/.bashrc && echo 'export PIP_CACHE_DIR=~/.cache/pip' >> ~/.bashrc && echo "alias git='git --no-pager'" >> ~/.bashrc"""
command=f"""echo 'export SWE_INSTANCE_ID={instance['instance_id']}' >> ~/.bashrc && echo 'export PIP_CACHE_DIR=~/.cache/pip' >> ~/.bashrc && echo "alias git='git --no-pager'" >> ~/.bashrc && git config --global core.pager "" && git config --global diff.binary false"""
)
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
obs.exit_code == 0, f'Failed to export SWE_INSTANCE_ID: {str(obs)}'
obs.exit_code == 0,
f'Failed to export SWE_INSTANCE_ID and configure git: {str(obs)}',
)
action = CmdRunAction(command="""export USER=$(whoami); echo USER=${USER} """)
@@ -334,15 +340,18 @@ def initialize_runtime(
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(obs.exit_code == 0, f'Failed to remove git remotes: {str(obs)}')
action = CmdRunAction(command='which python')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
obs.exit_code == 0 and 'testbed' in obs.content,
f'Expected to find python interpreter from testbed, but got: {str(obs)}',
)
if 'multimodal' not in metadata.dataset.lower():
# Only for non-multimodal datasets, we need to activate the testbed environment for Python
# SWE-Bench multimodal datasets are not using the testbed environment
action = CmdRunAction(command='which python')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
obs.exit_code == 0 and 'testbed' in obs.content,
f'Expected to find python interpreter from testbed, but got: {str(obs)}',
)
logger.info('-' * 30)
logger.info('END Runtime Initialization Fn')
@@ -452,11 +461,22 @@ def complete_runtime(
f'Failed to git add -A: {str(obs)}',
)
# Remove binary files from git staging
action = CmdRunAction(command=remove_binary_files_from_git())
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
isinstance(obs, CmdOutputObservation) and obs.exit_code == 0,
f'Failed to remove binary files: {str(obs)}',
)
n_retries = 0
git_patch = None
while n_retries < 5:
action = CmdRunAction(
command=f'git diff --no-color --cached {instance["base_commit"]}'
command=f'git diff --no-color --cached {instance["base_commit"]} > patch.diff'
)
action.set_hard_timeout(max(300 + 100 * n_retries, 600))
logger.info(action, extra={'msg_type': 'ACTION'})
@@ -465,8 +485,28 @@ def complete_runtime(
n_retries += 1
if isinstance(obs, CmdOutputObservation):
if obs.exit_code == 0:
git_patch = obs.content.strip()
break
# Read the patch file
action = FileReadAction(path='patch.diff')
action.set_hard_timeout(max(300 + 100 * n_retries, 600))
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
if isinstance(obs, FileReadObservation):
git_patch = obs.content
break
elif isinstance(obs, ErrorObservation):
# Fall back to cat "patch.diff" to get the patch
assert 'File could not be decoded as utf-8' in obs.content
action = CmdRunAction(command='cat patch.diff')
action.set_hard_timeout(max(300 + 100 * n_retries, 600))
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
assert isinstance(obs, CmdOutputObservation) and obs.exit_code == 0
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
git_patch = obs.content
break
else:
assert_and_raise(False, f'Unexpected observation type: {str(obs)}')
else:
logger.info('Failed to get git diff, retrying...')
sleep_if_should_continue(10)
@@ -478,6 +518,9 @@ def complete_runtime(
assert_and_raise(git_patch is not None, 'Failed to get git diff (None)')
# Remove binary diffs from the patch
git_patch = remove_binary_diffs(git_patch)
logger.info('-' * 30)
logger.info('END Runtime Completion Fn')
logger.info('-' * 30)
@@ -761,9 +804,19 @@ if __name__ == '__main__':
with open(cur_output_file, 'r') as f:
for line in f:
instance = json.loads(line)
history = [event_from_dict(event) for event in instance['history']]
critic_result = critic.evaluate(history)
if not critic_result.success:
try:
history = [
event_from_dict(event) for event in instance['history']
]
critic_result = critic.evaluate(
history, instance['test_result'].get('git_patch', '')
)
if not critic_result.success:
instances_failed.append(instance['instance_id'])
except Exception as e:
logger.error(
f'Error loading history for instance {instance["instance_id"]}: {e}'
)
instances_failed.append(instance['instance_id'])
logger.info(
f'{len(instances_failed)} instances failed the current attempt {attempt}: {instances_failed}'
@@ -792,7 +845,11 @@ if __name__ == '__main__':
with open(cur_output_file, 'r') as f:
for line in f:
instance = json.loads(line)
if instance['instance_id'] not in added_instance_ids:
# Also make sure git_patch is not empty - otherwise we fall back to previous attempt (empty patch is worse than anything else)
if (
instance['instance_id'] not in added_instance_ids
and instance['test_result']['git_patch'].strip()
):
fout.write(line)
added_instance_ids.add(instance['instance_id'])
logger.info(

View File

@@ -18,6 +18,7 @@ if [[ -z "$item" ]]; then
exit 1
fi
WORKSPACE_NAME=$(echo "$item" | jq -r '(.repo | tostring) + "__" + (.version | tostring) | gsub("/"; "__")')
echo "WORKSPACE_NAME: $WORKSPACE_NAME"
@@ -36,5 +37,7 @@ mkdir -p /workspace
cp -r /testbed /workspace/$WORKSPACE_NAME
# Activate instance-specific environment
. /opt/miniconda3/etc/profile.d/conda.sh
conda activate testbed
if [ -d /opt/miniconda3 ]; then
. /opt/miniconda3/etc/profile.d/conda.sh
conda activate testbed
fi

View File

@@ -1,4 +1,4 @@
#!/bin/sh
cd frontend
lint-staged
vitest run
npm run check-unlocalized-strings
npx lint-staged
npm test

View File

@@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { SettingsService } from "#/api/settings-service/settings-service.api";
describe("AnalyticsConsentFormModal", () => {
it("should call saveUserSettings with consent", async () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
const saveUserSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
render(<AnalyticsConsentFormModal onClose={onCloseMock} />, {
wrapper: ({ children }) => (

View File

@@ -1,10 +1,10 @@
import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeAll, afterAll } from "vitest";
import userEvent from "@testing-library/user-event";
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
import { AuthModal } from "#/components/features/waitlist/auth-modal";
import * as CaptureConsent from "#/utils/handle-capture-consent";
describe("WaitlistModal", () => {
describe("AuthModal", () => {
beforeAll(() => {
vi.stubGlobal("location", { href: "" });
});
@@ -14,7 +14,7 @@ describe("WaitlistModal", () => {
});
it("should render a tos checkbox that is unchecked by default", () => {
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
render(<AuthModal githubAuthUrl={null} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).not.toBeChecked();
@@ -22,7 +22,7 @@ describe("WaitlistModal", () => {
it("should only enable the GitHub button if the tos checkbox is checked", async () => {
const user = userEvent.setup();
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
render(<AuthModal githubAuthUrl={null} />);
const checkbox = screen.getByRole("checkbox");
const button = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
@@ -40,7 +40,7 @@ describe("WaitlistModal", () => {
);
const user = userEvent.setup();
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl="mock-url" />);
render(<AuthModal githubAuthUrl="mock-url" />);
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);

View File

@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { SettingsService } from "#/api/settings-service/settings-service.api";
import OpenHands from "#/api/open-hands";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
@@ -18,7 +18,7 @@ const renderSidebar = () =>
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
describe("Sidebar", () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
afterEach(() => {
vi.clearAllMocks();

View File

@@ -3,20 +3,20 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { screen } from "@testing-library/react";
import OpenHands from "#/api/open-hands";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { SettingsService } from "#/api/settings-service/settings-service.api";
describe("SettingsForm", () => {
const onCloseMock = vi.fn();
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const RouteStub = createRoutesStub([
{
Component: () => (
<SettingsForm
settings={DEFAULT_SETTINGS}
models={[DEFAULT_SETTINGS.llm_model]}
models={[DEFAULT_SETTINGS.LLM_MODEL]}
onClose={onCloseMock}
/>
),
@@ -33,7 +33,7 @@ describe("SettingsForm", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: DEFAULT_SETTINGS.llm_model,
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
}),
);
});

View File

@@ -1,13 +1,13 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { AuthProvider } from "#/context/auth-context";
import { SettingsService } from "#/api/settings-service/settings-service.api";
describe("useSaveSettings", () => {
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), {
wrapper: ({ children }) => (
<AuthProvider>

View File

@@ -7,7 +7,6 @@ import MainApp from "#/routes/root-layout";
import i18n from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import OpenHands from "#/api/open-hands";
import { SettingsService } from "#/api/settings-service/settings-service.api";
describe("frontend/routes/_oh", () => {
const RouteStub = createRoutesStub([{ Component: MainApp, path: "/" }]);
@@ -60,7 +59,7 @@ describe("frontend/routes/_oh", () => {
it.skip("should render and capture the user's consent if oss mode", async () => {
const user = userEvent.setup();
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const handleCaptureConsentSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",

View File

@@ -8,7 +8,6 @@ import MainApp from "#/routes/root-layout";
import SettingsScreen from "#/routes/settings";
import Home from "#/routes/home";
import OpenHands from "#/api/open-hands";
import { SettingsService } from "#/api/settings-service/settings-service.api";
const createAxiosNotFoundErrorObject = () =>
new AxiosError(
@@ -26,7 +25,7 @@ const createAxiosNotFoundErrorObject = () =>
},
);
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const RouterStub = createRoutesStub([
{

View File

@@ -7,26 +7,25 @@ import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import SettingsScreen from "#/routes/settings";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
import AccountSettings from "#/routes/account-settings";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { SettingsService } from "#/api/settings-service/settings-service.api";
import { GitProvider } from "#/api/settings-service/settings-service.types";
import { Provider } from "#/types/settings";
const toggleAdvancedSettings = async (user: UserEvent) => {
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
await user.click(advancedSwitch);
};
const MOCK_PROVIDER_TOKENS_ARE_SET: Record<GitProvider, boolean> = {
const mock_provider_tokens_are_set: Record<Provider, boolean> = {
github: true,
gitlab: false,
};
describe("Settings Screen", () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const resetSettingsSpy = vi.spyOn(SettingsService, "resetSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const resetSettingsSpy = vi.spyOn(OpenHands, "resetSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const { handleLogoutMock } = vi.hoisted(() => ({
@@ -66,9 +65,7 @@ describe("Settings Screen", () => {
await waitFor(() => {
// Use queryAllByText to handle multiple elements with the same text
expect(screen.queryAllByText("SETTINGS$LLM_SETTINGS")).not.toHaveLength(
0,
);
expect(screen.queryAllByText("SETTINGS$LLM_SETTINGS")).not.toHaveLength(0);
screen.getByText("ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS");
screen.getByText("BUTTON$RESET_TO_DEFAULTS");
screen.getByText("BUTTON$SAVE");
@@ -101,7 +98,9 @@ describe("Settings Screen", () => {
// TODO: Set a better unset indicator
it.skip("should render an indicator if the GitHub token is not set", async () => {
getSettingsSpy.mockResolvedValue(DEFAULT_SETTINGS);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
});
renderSettingsScreen();
@@ -120,8 +119,8 @@ describe("Settings Screen", () => {
it("should set '<hidden>' placeholder if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
provider_tokens_set: MOCK_PROVIDER_TOKENS_ARE_SET,
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: mock_provider_tokens_are_set,
});
renderSettingsScreen();
@@ -134,8 +133,8 @@ describe("Settings Screen", () => {
it("should render an indicator if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
provider_tokens_set: MOCK_PROVIDER_TOKENS_ARE_SET,
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: mock_provider_tokens_are_set,
});
renderSettingsScreen();
@@ -151,6 +150,8 @@ describe("Settings Screen", () => {
}
});
// Tests for DISCONNECT_FROM_GITHUB button removed as the button is no longer included in main
it("should not render the 'Configure GitHub Repositories' button if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
@@ -209,7 +210,7 @@ describe("Settings Screen", () => {
it.skip("should not reset LLM Provider and Model if GitHub token is invalid", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "anthropic/claude-3-5-sonnet-20241022",
});
saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token"));
@@ -330,8 +331,8 @@ describe("Settings Screen", () => {
// TODO: Set a better unset indicator
it.skip("should render an indicator if the LLM API key is not set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...DEFAULT_SETTINGS,
llm_api_key_set: null,
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: null,
});
renderSettingsScreen();
@@ -351,7 +352,7 @@ describe("Settings Screen", () => {
it("should render an indicator if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key_set: true,
});
@@ -372,7 +373,7 @@ describe("Settings Screen", () => {
it("should set '<hidden>' placeholder if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key_set: true,
});
@@ -387,7 +388,7 @@ describe("Settings Screen", () => {
describe("Basic Model Selector", () => {
it("should set the provider and model", async () => {
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "anthropic/claude-3-5-sonnet-20241022",
});
@@ -454,7 +455,7 @@ describe("Settings Screen", () => {
});
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
remote_runtime_resource_factor: 1,
});
@@ -494,7 +495,7 @@ describe("Settings Screen", () => {
});
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
});
renderSettingsScreen();
@@ -545,7 +546,7 @@ describe("Settings Screen", () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValueOnce({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
});
renderSettingsScreen();
@@ -562,7 +563,7 @@ describe("Settings Screen", () => {
// Mock the settings that will be returned after reset
// This should be the default settings with no advanced settings enabled
getSettingsSpy.mockResolvedValueOnce({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
llm_base_url: "",
confirmation_mode: false,
security_analyzer: "",
@@ -613,7 +614,7 @@ describe("Settings Screen", () => {
it("should toggle advanced if user had set a custom model", async () => {
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "some/custom-model",
});
renderSettingsScreen();
@@ -648,7 +649,7 @@ describe("Settings Screen", () => {
it("should have confirmation mode enabled if the user previously had it enabled", async () => {
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
confirmation_mode: true,
});
@@ -665,7 +666,7 @@ describe("Settings Screen", () => {
// FIXME: security analyzer is not found for some reason...
it.skip("should have the values set if the user previously had them set", async () => {
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
user_consents_to_analytics: true,
llm_base_url: "https://test.com",
@@ -700,7 +701,7 @@ describe("Settings Screen", () => {
it("should save the settings when the 'Save Changes' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
});
renderSettingsScreen();
@@ -727,7 +728,7 @@ describe("Settings Screen", () => {
it("should properly save basic LLM model settings", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
});
renderSettingsScreen();
@@ -763,7 +764,7 @@ describe("Settings Screen", () => {
it("should reset the settings when the 'Reset to defaults' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue(DEFAULT_SETTINGS);
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderSettingsScreen();
@@ -794,7 +795,7 @@ describe("Settings Screen", () => {
// Mock the settings response after reset
getSettingsSpy.mockResolvedValueOnce({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
llm_base_url: "",
confirmation_mode: false,
security_analyzer: "",
@@ -819,7 +820,7 @@ describe("Settings Screen", () => {
it("should cancel the reset when the 'Cancel' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue(DEFAULT_SETTINGS);
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderSettingsScreen();
@@ -927,7 +928,7 @@ describe("Settings Screen", () => {
it("should not send an empty LLM API Key if the user submits an empty string but already has it set", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...DEFAULT_SETTINGS,
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key_set: true,
});

View File

@@ -2,10 +2,7 @@ import { render, screen } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { ChatInput } from "#/components/features/chat/chat-input";
import path from 'path';
import { scanDirectoryForUnlocalizedStrings } from "#/utils/scan-unlocalized-strings-ast";
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -15,22 +12,17 @@ vi.mock("react-i18next", () => ({
describe("Check for hardcoded English strings", () => {
test("InteractiveChatBox should not have hardcoded English strings", () => {
const { container } = render(
<InteractiveChatBox
onSubmit={() => {}}
onStop={() => {}}
/>
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />,
);
// Get all text content
const text = container.textContent;
// List of English strings that should be translated
const hardcodedStrings = [
"What do you want to build?",
];
const hardcodedStrings = ["What do you want to build?"];
// Check each string
hardcodedStrings.forEach(str => {
hardcodedStrings.forEach((str) => {
expect(text).not.toContain(str);
});
});
@@ -39,23 +31,4 @@ describe("Check for hardcoded English strings", () => {
render(<ChatInput onSubmit={() => {}} />);
screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
});
test("No unlocalized strings should exist in frontend code", () => {
const srcPath = path.resolve(__dirname, '../../src');
// Get unlocalized strings using the AST scanner
// The scanner now properly handles CSS classes using AST information
const results = scanDirectoryForUnlocalizedStrings(srcPath);
// If we found any unlocalized strings, format them for output
if (results.size > 0) {
const formattedResults = Array.from(results.entries())
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
.join('\n');
throw new Error(
`Found unlocalized strings in the following files:${formattedResults}`
);
}
});
});
});

View File

@@ -12,7 +12,7 @@ describe("hasAdvancedSettingsSet", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
llm_base_url: "test",
LLM_BASE_URL: "test",
}),
).toBe(true);
});
@@ -21,7 +21,7 @@ describe("hasAdvancedSettingsSet", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
agent: "test",
AGENT: "test",
}),
).toBe(true);
});
@@ -30,7 +30,7 @@ describe("hasAdvancedSettingsSet", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
remote_runtime_resource_factor: 999,
REMOTE_RUNTIME_RESOURCE_FACTOR: 999,
}),
).toBe(true);
});
@@ -39,7 +39,7 @@ describe("hasAdvancedSettingsSet", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
confirmation_mode: true,
CONFIRMATION_MODE: true,
}),
).toBe(true);
});
@@ -48,7 +48,7 @@ describe("hasAdvancedSettingsSet", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
security_analyzer: "test",
SECURITY_ANALYZER: "test",
}),
).toBe(true);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,51 @@
{
"name": "openhands-frontend",
"version": "0.31.0",
"version": "0.32.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@heroui/react": "2.7.5",
"@heroui/react": "2.7.6",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.4.0",
"@react-router/serve": "^7.4.0",
"@react-router/node": "^7.5.0",
"@react-router/serve": "^7.5.0",
"@react-types/shared": "^3.28.0",
"@reduxjs/toolkit": "^2.6.1",
"@stripe/react-stripe-js": "^3.5.1",
"@stripe/stripe-js": "^6.1.0",
"@tanstack/react-query": "^5.69.0",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^7.0.0",
"@tanstack/react-query": "^5.72.1",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.8.4",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.6.2",
"framer-motion": "^12.6.3",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.25",
"jose": "^6.0.10",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.233.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"posthog-js": "^1.235.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.4.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.4.0",
"react-router": "^7.5.0",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.8",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.0.2",
"vite": "^6.2.3",
"tailwind-merge": "^3.2.0",
"vite": "^6.2.5",
"web-vitals": "^3.5.2",
"ws": "^8.18.1"
},
@@ -65,12 +65,8 @@
"lint": "eslint src --ext .ts,.tsx,.js && prettier --check src/**/*.{ts,tsx}",
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
"prepare": "cd .. && husky frontend/.husky",
"typecheck": "react-router typegen && tsc"
},
"husky": {
"hooks": {
"pre-commit": "npm run test && lint-staged"
}
"typecheck": "react-router typegen && tsc",
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs"
},
"lint-staged": {
"src/**/*.{ts,tsx,js}": [
@@ -84,22 +80,22 @@
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.51.1",
"@react-router/dev": "^7.4.0",
"@react-router/dev": "^7.5.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.68.0",
"@tanstack/eslint-plugin-query": "^5.72.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.2.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.13.14",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.3",
"@types/node": "^22.14.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.0.9",
"@vitest/coverage-v8": "^3.1.1",
"autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
@@ -108,18 +104,18 @@
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.5",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"husky": "^9.1.7",
"jsdom": "^26.0.0",
"lint-staged": "^15.5.0",
"msw": "^2.6.6",
"postcss": "^8.5.2",
"prettier": "^3.5.3",
"stripe": "^17.7.0",
"stripe": "^18.0.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2",
"typescript": "^5.8.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.2"

View File

@@ -0,0 +1,707 @@
#!/usr/bin/env node
/**
* Pre-commit hook script to check for unlocalized strings in the frontend code
* This script is based on the test in __tests__/utils/check-hardcoded-strings.test.tsx
*/
const path = require('path');
const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// Files/directories to ignore
const IGNORE_PATHS = [
// Build and dependency files
"node_modules",
"dist",
".git",
"test",
"__tests__",
".d.ts",
"i18n",
"package.json",
"package-lock.json",
"tsconfig.json",
// Internal code that doesn't need localization
"mocks", // Mock data
"assets", // SVG paths and CSS classes
"types", // Type definitions and constants
"state", // Redux state management
"api", // API endpoints
"services", // Internal services
"hooks", // React hooks
"context", // React context
"store", // Redux store
"routes.ts", // Route definitions
"root.tsx", // Root component
"entry.client.tsx", // Client entry point
"utils/scan-unlocalized-strings.ts", // Original scanner
"utils/scan-unlocalized-strings-ast.ts", // This file itself
];
// Extensions to scan
const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
// Attributes that typically don't contain user-facing text
const NON_TEXT_ATTRIBUTES = [
"className",
"testId",
"id",
"name",
"type",
"href",
"src",
"alt",
"placeholder",
"rel",
"target",
"style",
"onClick",
"onChange",
"onSubmit",
"data-testid",
"aria-label",
"aria-labelledby",
"aria-describedby",
"aria-hidden",
"role",
];
function shouldIgnorePath(filePath) {
return IGNORE_PATHS.some((ignore) => filePath.includes(ignore));
}
// Check if a string looks like a translation key
// Translation keys typically use dots, underscores, or are all caps
// Also check for the pattern with $ which is used in our translation keys
function isLikelyTranslationKey(str) {
return (
/^[A-Z0-9_$.]+$/.test(str) ||
str.includes(".") ||
/[A-Z0-9_]+\$[A-Z0-9_]+/.test(str)
);
}
// Check if a string is a raw translation key that should be wrapped in t()
function isRawTranslationKey(str) {
// Check for our specific translation key pattern (e.g., "SETTINGS$GITHUB_SETTINGS")
// Exclude specific keys that are already properly used with i18next.t() in the code
const excludedKeys = [
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
"ERROR$GENERIC",
"GITHUB$AUTH_SCOPE",
];
if (excludedKeys.includes(str)) {
return false;
}
return /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str);
}
// Specific technical strings that should be excluded from localization
const EXCLUDED_TECHNICAL_STRINGS = [
"openid email profile", // OAuth scope string - not user-facing
];
function isExcludedTechnicalString(str) {
return EXCLUDED_TECHNICAL_STRINGS.includes(str);
}
function isCommonDevelopmentString(str) {
// Technical patterns that are definitely not UI strings
const technicalPatterns = [
// URLs and paths
/^https?:\/\//, // URLs
/^\/[a-zA-Z0-9_\-./]*$/, // File paths
/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/, // File extensions, class names
/^@[a-zA-Z0-9/-]+$/, // Import paths
/^#\/[a-zA-Z0-9/-]+$/, // Alias imports
/^[a-zA-Z0-9/-]+\/[a-zA-Z0-9/-]+$/, // Module paths
/^data:image\/[a-zA-Z0-9;,]+$/, // Data URLs
/^application\/[a-zA-Z0-9-]+$/, // MIME types
/^!\[image]\(data:image\/png;base64,$/, // Markdown image with base64 data
// Numbers, IDs, and technical values
/^\d+(\.\d+)?$/, // Numbers
/^#[0-9a-fA-F]{3,8}$/, // Color codes
/^[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+$/, // Key-value pairs
/^mm:ss$/, // Time format
/^[a-zA-Z0-9]+\/[a-zA-Z0-9-]+$/, // Provider/model format
/^\?[a-zA-Z0-9_-]+$/, // URL parameters
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, // UUID
/^[A-Za-z0-9+/=]+$/, // Base64
// HTML and CSS selectors
/^[a-z]+(\[[^\]]+\])+$/, // CSS attribute selectors
/^[a-z]+:[a-z-]+$/, // CSS pseudo-selectors
/^[a-z]+\.[a-z0-9_-]+$/, // CSS class selectors
/^[a-z]+#[a-z0-9_-]+$/, // CSS ID selectors
/^[a-z]+\s*>\s*[a-z]+$/, // CSS child selectors
/^[a-z]+\s+[a-z]+$/, // CSS descendant selectors
// CSS and styling patterns
/^[a-z0-9-]+:[a-z0-9-]+$/, // CSS property:value
/^[a-z0-9-]+:[a-z0-9-]+;[a-z0-9-]+:[a-z0-9-]+$/, // Multiple CSS properties
];
// File extensions and media types
const fileExtensionPattern =
/^\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|pdf|mp4|webm|ogg|mp3|wav|json|xml|csv|txt|md|html|css|js|jsx|ts|tsx)$/i;
if (fileExtensionPattern.test(str)) {
return true;
}
// AI model and provider patterns
const aiRelatedPattern =
/^(AI|OpenAI|VertexAI|PaLM|Gemini|Anthropic|Anyscale|Databricks|Ollama|FriendliAI|Groq|DeepInfra|AI21|Replicate|OpenRouter|Azure|AWS|SageMaker|Bedrock|Mistral|Perplexity|Fireworks|Cloudflare|Workers|Voyage|claude-|gpt-|o1-|o3-)/i;
if (aiRelatedPattern.test(str)) {
return true;
}
// CSS units and values
const cssUnitsPattern =
/(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$/;
const cssValuesPattern =
/(rgb|rgba|hsl|hsla|#[0-9a-fA-F]+|solid|absolute|relative|sticky|fixed|static|block|inline|flex|grid|none|auto|hidden|visible)/;
if (cssUnitsPattern.test(str) || cssValuesPattern.test(str)) {
return true;
}
// Check for CSS class strings with brackets (common in the codebase)
if (
str.includes("[") &&
str.includes("]") &&
(str.includes("px") ||
str.includes("rem") ||
str.includes("em") ||
str.includes("w-") ||
str.includes("h-") ||
str.includes("p-") ||
str.includes("m-"))
) {
return true;
}
// Check for CSS class strings with specific patterns
if (
str.includes("border-") ||
str.includes("rounded-") ||
str.includes("cursor-") ||
str.includes("opacity-") ||
str.includes("disabled:") ||
str.includes("hover:") ||
str.includes("focus-within:") ||
str.includes("first-of-type:") ||
str.includes("last-of-type:") ||
str.includes("group-data-")
) {
return true;
}
// Check if it looks like a Tailwind class string
if (/^[a-z0-9-]+(\s+[a-z0-9-]+)*$/.test(str)) {
// Common Tailwind prefixes and patterns
const tailwindPrefixes = [
"bg-", "text-", "border-", "rounded-", "p-", "m-", "px-", "py-", "mx-", "my-",
"w-", "h-", "min-w-", "min-h-", "max-w-", "max-h-", "flex-", "grid-", "gap-",
"space-", "items-", "justify-", "self-", "col-", "row-", "order-", "object-",
"overflow-", "opacity-", "z-", "top-", "right-", "bottom-", "left-", "inset-",
"font-", "tracking-", "leading-", "list-", "placeholder-", "shadow-", "ring-",
"transition-", "duration-", "ease-", "delay-", "animate-", "scale-", "rotate-",
"translate-", "skew-", "origin-", "cursor-", "select-", "resize-", "fill-", "stroke-",
];
// Check if any word in the string starts with a Tailwind prefix
const words = str.split(/\s+/);
for (const word of words) {
for (const prefix of tailwindPrefixes) {
if (word.startsWith(prefix)) {
return true;
}
}
}
// Check for Tailwind modifiers
const tailwindModifiers = [
"hover:", "focus:", "active:", "disabled:", "visited:", "first:", "last:",
"odd:", "even:", "group-hover:", "focus-within:", "focus-visible:", "motion-safe:",
"motion-reduce:", "dark:", "light:", "sm:", "md:", "lg:", "xl:", "2xl:",
];
for (const word of words) {
for (const modifier of tailwindModifiers) {
if (word.includes(modifier)) {
return true;
}
}
}
// Check for CSS property combinations
const cssProperties = [
"border", "rounded", "px", "py", "mx", "my", "p", "m", "w", "h", "flex",
"grid", "gap", "transition", "duration", "font", "leading", "tracking",
];
// If the string contains multiple CSS properties, it's likely a CSS class string
let cssPropertyCount = 0;
for (const word of words) {
if (
cssProperties.some(
(prop) => word === prop || word.startsWith(`${prop}-`),
)
) {
cssPropertyCount += 1;
}
}
if (cssPropertyCount >= 2) {
return true;
}
}
// Check for specific CSS class patterns that appear in the test failures
if (
str.match(
/^(border|rounded|flex|grid|transition|duration|ease|hover:|focus:|active:|disabled:|placeholder:|text-|bg-|w-|h-|p-|m-|gap-|items-|justify-|self-|overflow-|cursor-|opacity-|z-|top-|right-|bottom-|left-|inset-|font-|tracking-|leading-|whitespace-|break-|truncate|shadow-|ring-|outline-|animate-|transform|rotate-|scale-|skew-|translate-|origin-|first-of-type:|last-of-type:|group-data-|max-|min-|px-|py-|mx-|my-|grow|shrink|resize-|underline|italic|normal)/,
)
) {
return true;
}
// HTML tags and attributes
if (
/^<[a-z0-9]+>.*<\/[a-z0-9]+>$/.test(str) ||
/^<[a-z0-9]+ [^>]+\/>$/.test(str)
) {
return true;
}
// Check for specific patterns in suggestions and examples
if (
str.includes("* ") &&
(str.includes("create a") ||
str.includes("build a") ||
str.includes("make a"))
) {
// This is likely a suggestion or example, not a UI string
return false;
}
// Check for specific technical identifiers from the test failures
if (
/^(download_via_vscode_button_clicked|open-vscode-error-|set-indicator|settings_saved|openhands-trace-|provider-item-|last_browser_action_error)$/.test(
str,
)
) {
return true;
}
// Check for URL paths and query parameters
if (
str.startsWith("?") ||
str.startsWith("/") ||
str.includes("auth.") ||
str.includes("$1auth.")
) {
return true;
}
// Check for specific strings that should be excluded
if (
str === "Cache Hit:" ||
str === "Cache Write:" ||
str === "ADD_DOCS" ||
str === "ADD_DOCKERFILE" ||
str === "Verified" ||
str === "Others" ||
str === "Feedback" ||
str === "JSON File" ||
str === "mt-0.5 md:mt-0"
) {
return true;
}
// Check for long suggestion texts
if (
str.length > 100 &&
(str.includes("Please write a bash script") ||
str.includes("Please investigate the repo") ||
str.includes("Please push the changes") ||
str.includes("Examine the dependencies") ||
str.includes("Investigate the documentation") ||
str.includes("Investigate the current repo") ||
str.includes("I want to create a Hello World app") ||
str.includes("I want to create a VueJS app") ||
str.includes("This should be a client-only app"))
) {
return true;
}
// Check for specific error messages and UI text
if (
str === "All data associated with this project will be lost." ||
str === "You will lose any unsaved information." ||
str ===
"This conversation does not exist, or you do not have permission to access it." ||
str === "Failed to fetch settings. Please try reloading." ||
str ===
"If you tell OpenHands to start a web server, the app will appear here." ||
str ===
"Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API." ||
str ===
"Something went wrong while fetching settings. Please reload the page." ||
str ===
"To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." ||
str === "Please push the latest changes to the existing pull request."
) {
return true;
}
// Check against all technical patterns
return technicalPatterns.some((pattern) => pattern.test(str));
}
function isLikelyUserFacingText(str) {
// Basic validation - skip very short strings or strings without letters
if (!str || str.length <= 2 || !/[a-zA-Z]/.test(str)) {
return false;
}
// Check if it's a specifically excluded technical string
if (isExcludedTechnicalString(str)) {
return false;
}
// Check if it's a raw translation key that should be wrapped in t()
if (isRawTranslationKey(str)) {
return true;
}
// Check if it's a translation key pattern (e.g., "SETTINGS$BASE_URL")
// These should be wrapped in t() or use I18nKey enum
if (isLikelyTranslationKey(str) && /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str)) {
return true;
}
// First, check if it's a common development string (not user-facing)
if (isCommonDevelopmentString(str)) {
return false;
}
// Multi-word phrases are likely UI text
const hasMultipleWords = /\s+/.test(str) && str.split(/\s+/).length > 1;
// Sentences and questions are likely UI text
const hasPunctuation = /[?!.,:]/.test(str);
const isCapitalizedPhrase = /^[A-Z]/.test(str) && hasMultipleWords;
const isTitleCase = hasMultipleWords && /\s[A-Z]/.test(str);
const hasSentenceStructure = /^[A-Z].*[.!?]$/.test(str); // Starts with capital, ends with punctuation
const hasQuestionForm =
/^(What|How|Why|When|Where|Who|Can|Could|Would|Will|Is|Are|Do|Does|Did|Should|May|Might)/.test(
str,
);
// Product names and camelCase identifiers are likely UI text
const hasInternalCapitals = /[a-z][A-Z]/.test(str); // CamelCase product names
// Instruction text patterns are likely UI text
const looksLikeInstruction =
/^(Enter|Type|Select|Choose|Provide|Specify|Search|Find|Input|Add|Write|Describe|Set|Pick|Browse|Upload|Download|Click|Tap|Press|Go to|Visit|Open|Close)/i.test(
str,
);
// Error and status messages are likely UI text
const looksLikeErrorOrStatus =
/(failed|error|invalid|required|missing|incorrect|wrong|unavailable|not found|not available|try again|success|completed|finished|done|saved|updated|created|deleted|removed|added)/i.test(
str,
);
// Single word check - assume it's UI text unless proven otherwise
const isSingleWord =
!str.includes(" ") && str.length > 1 && /^[a-zA-Z]+$/.test(str);
// For single words, we need to be more careful
if (isSingleWord) {
// Skip common programming terms and variable names
const isCommonProgrammingTerm =
/^(null|undefined|true|false|function|class|interface|type|enum|const|let|var|return|import|export|default|async|await|try|catch|finally|throw|new|this|super|extends|implements|instanceof|typeof|void|delete|in|of|for|while|do|if|else|switch|case|break|continue|yield|static|get|set|public|private|protected|readonly|abstract|implements|namespace|module|declare|as|from|with)$/i.test(
str,
);
if (isCommonProgrammingTerm) {
return false;
}
// Skip common variable name patterns
const looksLikeVariableName =
/^[a-z][a-zA-Z0-9]*$/.test(str) && str.length <= 20;
if (looksLikeVariableName) {
return false;
}
// Skip common CSS values
const isCommonCssValue =
/^(auto|none|hidden|visible|block|inline|flex|grid|row|column|wrap|nowrap|center|start|end|stretch|cover|contain|fixed|absolute|relative|static|sticky|pointer|default|inherit|initial|unset)$/i.test(
str,
);
if (isCommonCssValue) {
return false;
}
// Skip common file extensions
const isFileExtension = /^\.[a-z0-9]+$/i.test(str);
if (isFileExtension) {
return false;
}
// Skip common abbreviations
const isCommonAbbreviation =
/^(id|src|href|url|alt|img|btn|nav|div|span|ul|li|ol|dl|dt|dd|svg|png|jpg|gif|pdf|doc|txt|md|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|toml|csv|mp3|mp4|wav|avi|mov|mpeg|webm|webp|ttf|woff|eot|otf)$/i.test(
str,
);
if (isCommonAbbreviation) {
return false;
}
// If it's a single word that's not a programming term, variable name, CSS value, file extension, or abbreviation,
// it might be UI text, but we'll be conservative and return false
return false;
}
// If it has multiple words, punctuation, or looks like a sentence, it's likely UI text
return (
hasMultipleWords ||
hasPunctuation ||
isCapitalizedPhrase ||
isTitleCase ||
hasSentenceStructure ||
hasQuestionForm ||
hasInternalCapitals ||
looksLikeInstruction ||
looksLikeErrorOrStatus
);
}
function isInTranslationContext(path) {
// Check if the JSX text is inside a <Trans> component
let current = path;
while (current.parentPath) {
if (
current.isJSXElement() &&
current.node.openingElement &&
current.node.openingElement.name &&
current.node.openingElement.name.name === "Trans"
) {
return true;
}
current = current.parentPath;
}
return false;
}
function scanFileForUnlocalizedStrings(filePath) {
// Skip all suggestion files as they contain special strings
if (filePath.includes("suggestions")) {
return [];
}
try {
const content = fs.readFileSync(filePath, "utf-8");
const unlocalizedStrings = [];
// Skip files that are too large
if (content.length > 1000000) {
console.warn(`Skipping large file: ${filePath}`);
return [];
}
try {
// Parse the file
const ast = parser.parse(content, {
sourceType: "module",
plugins: ["jsx", "typescript", "classProperties", "decorators-legacy"],
});
// Traverse the AST
traverse(ast, {
// Find JSX text content
JSXText(jsxTextPath) {
const text = jsxTextPath.node.value.trim();
if (
text &&
isLikelyUserFacingText(text) &&
!isInTranslationContext(jsxTextPath)
) {
unlocalizedStrings.push(text);
}
},
// Find string literals in JSX attributes
JSXAttribute(jsxAttrPath) {
const attrName = jsxAttrPath.node.name.name.toString();
// Skip technical attributes that don't contain user-facing text
if (NON_TEXT_ATTRIBUTES.includes(attrName)) {
return;
}
// Skip styling attributes
if (
attrName === "className" ||
attrName === "class" ||
attrName === "style"
) {
return;
}
// Skip data attributes and event handlers
if (attrName.startsWith("data-") || attrName.startsWith("on")) {
return;
}
// Check the attribute value
const value = jsxAttrPath.node.value;
if (value && value.type === "StringLiteral") {
const text = value.value.trim();
if (text && isLikelyUserFacingText(text)) {
unlocalizedStrings.push(text);
}
}
},
// Find string literals in code
StringLiteral(stringPath) {
// Skip if parent is JSX attribute (already handled above)
if (stringPath.parent.type === "JSXAttribute") {
return;
}
// Skip if parent is import/export declaration
if (
stringPath.parent.type === "ImportDeclaration" ||
stringPath.parent.type === "ExportDeclaration"
) {
return;
}
// Skip if parent is object property key
if (
stringPath.parent.type === "ObjectProperty" &&
stringPath.parent.key === stringPath.node
) {
return;
}
// Skip if inside a t() call or Trans component
let isInsideTranslation = false;
let current = stringPath;
while (current.parentPath && !isInsideTranslation) {
// Check for t() function call
if (
current.parent.type === "CallExpression" &&
current.parent.callee &&
((current.parent.callee.type === "Identifier" &&
current.parent.callee.name === "t") ||
(current.parent.callee.type === "MemberExpression" &&
current.parent.callee.property &&
current.parent.callee.property.name === "t"))
) {
isInsideTranslation = true;
break;
}
// Check for <Trans> component
if (
current.parent.type === "JSXElement" &&
current.parent.openingElement &&
current.parent.openingElement.name &&
current.parent.openingElement.name.name === "Trans"
) {
isInsideTranslation = true;
break;
}
current = current.parentPath;
}
if (!isInsideTranslation) {
const text = stringPath.node.value.trim();
if (text && isLikelyUserFacingText(text)) {
unlocalizedStrings.push(text);
}
}
},
});
return unlocalizedStrings;
} catch (error) {
console.error(`Error parsing file ${filePath}:`, error);
return [];
}
} catch (error) {
console.error(`Error reading file ${filePath}:`, error);
return [];
}
}
function scanDirectoryForUnlocalizedStrings(dirPath) {
const results = new Map();
function scanDir(currentPath) {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
if (!shouldIgnorePath(fullPath)) {
if (entry.isDirectory()) {
scanDir(fullPath);
} else if (
entry.isFile() &&
SCAN_EXTENSIONS.includes(path.extname(fullPath))
) {
const unlocalized = scanFileForUnlocalizedStrings(fullPath);
if (unlocalized.length > 0) {
results.set(fullPath, unlocalized);
}
}
}
}
}
scanDir(dirPath);
return results;
}
// Run the check
try {
const srcPath = path.resolve(__dirname, '../src');
console.log('Checking for unlocalized strings in frontend code...');
// Get unlocalized strings using the AST scanner
const results = scanDirectoryForUnlocalizedStrings(srcPath);
// If we found any unlocalized strings, format them for output and exit with error
if (results.size > 0) {
const formattedResults = Array.from(results.entries())
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
.join('\n');
console.error(`Error: Found unlocalized strings in the following files:${formattedResults}`);
process.exit(1);
}
console.log('✅ No unlocalized strings found in frontend code.');
process.exit(0);
} catch (error) {
console.error('Error running unlocalized strings check:', error);
process.exit(1);
}

View File

@@ -10,6 +10,7 @@ import {
GetTrajectoryResponse,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { GitUser, GitRepository } from "#/types/git";
class OpenHands {
@@ -177,6 +178,33 @@ class OpenHands {
return data;
}
/**
* Get the settings from the server or use the default settings if not found
*/
static async getSettings(): Promise<ApiSettings> {
const { data } = await openHands.get<ApiSettings>("/api/settings");
return data;
}
/**
* Save the settings to the server. Only valid settings are saved.
* @param settings - the settings to save
*/
static async saveSettings(
settings: Partial<PostApiSettings>,
): Promise<boolean> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}
/**
* Reset user settings in server
*/
static async resetSettings(): Promise<boolean> {
const response = await openHands.post("/api/reset-settings");
return response.status === 200;
}
static async createCheckoutSession(amount: number): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-checkout-session",

View File

@@ -1,29 +0,0 @@
import { openHands } from "../open-hands-axios";
import { UserSettings } from "./settings-service.types";
export class SettingsService {
/**
* Get the user's settings
*/
static async getSettings(): Promise<UserSettings> {
const { data } = await openHands.get<UserSettings>("/api/settings");
return data;
}
/**
* Save valid settings to the server
* @param settings - The settings to save
*/
static async saveSettings(settings: Partial<UserSettings>): Promise<boolean> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}
/**
* Reset the user's settings
*/
static async resetSettings(): Promise<boolean> {
const response = await openHands.post("/api/settings/reset");
return response.status === 200;
}
}

View File

@@ -1,27 +0,0 @@
export type GitProvider = "github" | "gitlab";
export interface UserSettings {
llm_model: string;
llm_base_url: string;
agent: string;
language: string;
llm_api_key_set: boolean;
confirmation_mode: boolean;
security_analyzer: string;
remote_runtime_resource_factor: number | null;
github_token_is_set: boolean;
enable_default_condenser: boolean;
enable_sound_notifications: boolean;
user_consents_to_analytics: boolean | null;
provider_tokens_set: Record<GitProvider, boolean>;
}
// These are settings that are only used on the client side and should not be sent to the server
export interface ClientUserSettings extends UserSettings {
is_new_user: boolean;
}
// These are settings that are used on the server side and should be sent to the server
export interface ServerUserSettings extends UserSettings {
github_token: string | null;
}

View File

@@ -57,7 +57,7 @@ export function ChatMessage({
onClick={handleCopyToClipboard}
mode={isCopy ? "copied" : "copy"}
/>
<div className="text-sm overflow-auto break-words">
<div className="text-sm break-words">
<Markdown
components={{
code,

View File

@@ -189,7 +189,7 @@ export function ExpandableMessage({
)}
</div>
{showDetails && (
<div className="text-sm overflow-auto">
<div className="text-sm">
<Markdown
components={{
code,

View File

@@ -2,8 +2,6 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { JoinWaitlistAnchor } from "./join-waitlist-anchor";
import { WaitlistMessage } from "./waitlist-message";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { TOSCheckbox } from "./tos-checkbox";
@@ -11,15 +9,11 @@ import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { BrandButton } from "../settings/brand-button";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
interface WaitlistModalProps {
ghTokenIsSet: boolean;
interface AuthModalProps {
githubAuthUrl: string | null;
}
export function WaitlistModal({
ghTokenIsSet,
githubAuthUrl,
}: WaitlistModalProps) {
export function AuthModal({ githubAuthUrl }: AuthModalProps) {
const { t } = useTranslation();
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
@@ -34,23 +28,24 @@ export function WaitlistModal({
<ModalBackdrop>
<ModalBody className="border border-tertiary">
<AllHandsLogo width={68} height={46} />
<WaitlistMessage content={ghTokenIsSet ? "waitlist" : "sign-in"} />
<div className="flex flex-col gap-2 w-full items-center text-center">
<h1 className="text-2xl font-bold">
{t(I18nKey.AUTH$SIGN_IN_WITH_GITHUB)}
</h1>
</div>
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
{!ghTokenIsSet && (
<BrandButton
isDisabled={!isTosAccepted}
type="button"
variant="primary"
onClick={handleGitHubAuth}
className="w-full"
startContent={<GitHubLogo width={20} height={20} />}
>
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
</BrandButton>
)}
{ghTokenIsSet && <JoinWaitlistAnchor />}
<BrandButton
isDisabled={!isTosAccepted}
type="button"
variant="primary"
onClick={handleGitHubAuth}
className="w-full"
startContent={<GitHubLogo width={20} height={20} />}
>
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
</BrandButton>
</ModalBody>
</ModalBackdrop>
);

View File

@@ -1,17 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function JoinWaitlistAnchor() {
const { t } = useTranslation();
return (
<a
href="https://www.all-hands.dev/join-waitlist"
target="_blank"
rel="noreferrer"
className="rounded bg-[#FFE165] text-black text-sm font-bold py-[10px] w-full text-center hover:opacity-80"
>
{t(I18nKey.WAITLIST$JOIN_WAITLIST)}
</a>
);
}

View File

@@ -1,36 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface WaitlistMessageProps {
content: "waitlist" | "sign-in";
}
export function WaitlistMessage({ content }: WaitlistMessageProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 w-full items-center text-center">
<h1 className="text-2xl font-bold">
{content === "sign-in" && t(I18nKey.AUTH$SIGN_IN_WITH_GITHUB)}
{content === "waitlist" && t(I18nKey.WAITLIST$ALMOST_THERE)}
</h1>
{content === "sign-in" && (
<p>
{t(I18nKey.LANDING$OR)}{" "}
<a
href="https://www.all-hands.dev/join-waitlist"
target="_blank"
rel="noreferrer noopener"
className="text-blue-500 hover:underline underline-offset-2"
>
{t(I18nKey.WAITLIST$JOIN)}
</a>{" "}
{t(I18nKey.WAITLIST$IF_NOT_JOINED)}
</p>
)}
{content === "waitlist" && (
<p className="text-sm">{t(I18nKey.WAITLIST$PATIENCE_MESSAGE)}</p>
)}
</div>
);
}

View File

@@ -9,16 +9,15 @@ import { extractSettings } from "#/utils/settings-utils";
import { useEndSession } from "#/hooks/use-end-session";
import { ModalBackdrop } from "../modal-backdrop";
import { ModelSelector } from "./model-selector";
import { Settings } from "#/types/settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { HelpLink } from "#/components/features/settings/help-link";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { UserSettings } from "#/api/settings-service/settings-service.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
interface SettingsFormProps {
settings: UserSettings | undefined;
settings: Settings;
models: string[];
onClose: () => void;
}
@@ -43,16 +42,17 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
const handleFormSubmission = async (formData: FormData) => {
const newSettings = extractSettings(formData);
saveUserSettings(newSettings, {
await saveUserSettings(newSettings, {
onSuccess: () => {
onClose();
resetOngoingSession();
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.llm_model,
LLM_API_KEY: newSettings.llm_api_key_set ? "SET" : "UNSET",
LLM_MODEL: newSettings.LLM_MODEL,
LLM_API_KEY_SET: newSettings.LLM_API_KEY_SET ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
newSettings.remote_runtime_resource_factor,
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
});
},
});
@@ -74,7 +74,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
}
};
const isLLMKeySet = !!settings?.llm_api_key_set;
const isLLMKeySet = settings.LLM_API_KEY_SET;
return (
<div>
@@ -87,7 +87,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
<div className="flex flex-col gap-4">
<ModelSelector
models={organizeModelsAndProviders(models)}
currentModel={settings?.llm_model || DEFAULT_SETTINGS.llm_model}
currentModel={settings.LLM_MODEL}
/>
<SettingsInput

View File

@@ -5,10 +5,11 @@ import { I18nKey } from "#/i18n/declaration";
import { LoadingSpinner } from "../../loading-spinner";
import { ModalBackdrop } from "../modal-backdrop";
import { SettingsForm } from "./settings-form";
import { UserSettings } from "#/api/settings-service/settings-service.types";
import { Settings } from "#/types/settings";
import { DEFAULT_SETTINGS } from "#/services/settings";
interface SettingsModalProps {
settings: UserSettings | undefined;
settings?: Settings;
onClose: () => void;
}
@@ -46,7 +47,7 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
)}
{aiConfigOptions.data && (
<SettingsForm
settings={settings}
settings={settings || DEFAULT_SETTINGS}
models={aiConfigOptions.data?.models}
onClose={onClose}
/>

View File

@@ -1,29 +1,37 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_SETTINGS } from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { PostSettings, PostApiSettings } from "#/types/settings";
import { useSettings } from "../query/use-settings";
import { UserSettings } from "#/api/settings-service/settings-service.types";
import { SettingsService } from "#/api/settings-service/settings-service.api";
const saveSettingsMutationFn = async (
settings: Partial<UserSettings> | null,
settings: Partial<PostSettings> | null,
) => {
// If settings is null, we're resetting
if (settings === null) {
await SettingsService.resetSettings();
await OpenHands.resetSettings();
return;
}
const safeSettings: Partial<UserSettings> = {
...settings,
agent: settings.agent || DEFAULT_SETTINGS.agent,
language: settings.language || DEFAULT_SETTINGS.language,
llm_api_key_set:
settings.llm_api_key_set === ""
const apiSettings: Partial<PostApiSettings> = {
llm_model: settings.LLM_MODEL,
llm_base_url: settings.LLM_BASE_URL,
agent: settings.AGENT || DEFAULT_SETTINGS.AGENT,
language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: settings.CONFIRMATION_MODE,
security_analyzer: settings.SECURITY_ANALYZER,
llm_api_key:
settings.llm_api_key === ""
? ""
: settings.llm_api_key_set?.trim() || undefined,
: settings.llm_api_key?.trim() || undefined,
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: settings.user_consents_to_analytics,
provider_tokens: settings.provider_tokens,
};
await SettingsService.saveSettings(safeSettings);
await OpenHands.saveSettings(apiSettings);
};
export const useSaveSettings = () => {
@@ -31,7 +39,7 @@ export const useSaveSettings = () => {
const { data: currentSettings } = useSettings();
return useMutation({
mutationFn: async (settings: Partial<UserSettings> | null) => {
mutationFn: async (settings: Partial<PostSettings> | null) => {
if (settings === null) {
await saveSettingsMutationFn(null);
return;

View File

@@ -1,14 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { SettingsService } from "#/api/settings-service/settings-service.api";
import { ClientUserSettings } from "#/api/settings-service/settings-service.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
const settingsQueryFn = async (): Promise<ClientUserSettings> => {
const settings = await SettingsService.getSettings();
return { ...settings, is_new_user: false };
const getSettingsQueryFn = async () => {
const apiSettings = await OpenHands.getSettings();
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY_SET: apiSettings.llm_api_key_set,
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set,
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
PROVIDER_TOKENS: apiSettings.provider_tokens,
IS_NEW_USER: false,
};
};
export const useSettings = () => {
@@ -17,7 +32,7 @@ export const useSettings = () => {
const query = useQuery({
queryKey: ["settings", providerTokensSet],
queryFn: settingsQueryFn,
queryFn: getSettingsQueryFn,
// Only retry if the error is not a 404 because we
// would want to show the modal immediately if the
// settings are not found
@@ -30,36 +45,42 @@ export const useSettings = () => {
});
React.useEffect(() => {
if (query.isFetched && query.data?.llm_api_key_set) {
if (query.isFetched && query.data?.LLM_API_KEY_SET) {
posthog.capture("user_activated");
}
}, [query.data?.llm_api_key_set, query.isFetched]);
}, [query.data?.LLM_API_KEY_SET, query.isFetched]);
React.useEffect(() => {
if (query.isFetched && query.data?.provider_tokens_set) {
const providers = query.data.provider_tokens_set;
if (query.data?.PROVIDER_TOKENS_SET) {
const providers = query.data.PROVIDER_TOKENS_SET;
const setProviders = (
Object.keys(providers) as Array<keyof typeof providers>
).filter((key) => providers[key]);
setProviderTokensSet(setProviders);
const atLeastOneSet = Object.values(query.data.provider_tokens_set).some(
const atLeastOneSet = Object.values(query.data.PROVIDER_TOKENS_SET).some(
(value) => value,
);
setProvidersAreSet(atLeastOneSet);
}
}, [query.data?.provider_tokens_set, query.isFetched]);
}, [query.data?.PROVIDER_TOKENS_SET, query.isFetched]);
// We want to return the defaults if the settings aren't found so the user can still see the
// options to make their initial save. We don't set the defaults in `initialData` above because
// that would prepopulate the data to the cache and mess with expectations. Read more:
// https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#using-initialdata-to-prepopulate-a-query
if (query.error?.status === 404) {
// Object rest destructuring on a query will observe all changes to the query, leading to excessive re-renders.
// Only return the specific properties we need to avoid this.
// Create a new object with only the properties we need, avoiding rest destructuring
return {
data: DEFAULT_SETTINGS,
error: query.error,
isError: query.isError,
isLoading: query.isLoading,
isFetched: query.isFetched,
isFetching: query.isFetching,
isFetched: query.isFetched,
isSuccess: query.isSuccess,
status: query.status,
fetchStatus: query.fetchStatus,
refetch: query.refetch,
};
}

View File

@@ -15,7 +15,7 @@ export const useMigrateUserConsent = () => {
if (userAnalyticsConsent) {
args?.handleAnalyticsWasPresentInLocalStorage();
saveUserSettings(
await saveUserSettings(
{ user_consents_to_analytics: userAnalyticsConsent === "true" },
{
onSuccess: () => {

View File

@@ -26,7 +26,7 @@ export const useNotification = () => {
// 4. Not a settings-related notification
if (
options?.playSound === true && // Must be explicitly true
settings?.enable_sound_notifications &&
settings?.ENABLE_SOUND_NOTIFICATIONS &&
audioRef.current &&
!title.includes("BUTTON$") // Don't play for button/settings actions
) {
@@ -49,7 +49,7 @@ export const useNotification = () => {
return undefined;
},
[settings?.enable_sound_notifications],
[settings?.ENABLE_SOUND_NOTIFICATIONS],
);
return { notify };

View File

@@ -301,6 +301,7 @@ export enum I18nKey {
STATUS$ERROR_LLM_SERVICE_UNAVAILABLE = "STATUS$ERROR_LLM_SERVICE_UNAVAILABLE",
STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR = "STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR",
STATUS$ERROR_LLM_OUT_OF_CREDITS = "STATUS$ERROR_LLM_OUT_OF_CREDITS",
STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION = "STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION",
STATUS$ERROR_RUNTIME_DISCONNECTED = "STATUS$ERROR_RUNTIME_DISCONNECTED",
STATUS$LLM_RETRY = "STATUS$LLM_RETRY",
AGENT_ERROR$BAD_ACTION = "AGENT_ERROR$BAD_ACTION",

View File

@@ -4,11 +4,36 @@ import {
Conversation,
ResultSet,
} from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { SETTINGS_HANDLERS } from "./settings-handlers";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitUser } from "#/types/git";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
llm_api_key: null,
llm_api_key_set: DEFAULT_SETTINGS.LLM_API_KEY_SET,
agent: DEFAULT_SETTINGS.AGENT,
language: DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
provider_tokens_set: DEFAULT_SETTINGS.PROVIDER_TOKENS_SET,
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
provider_tokens: DEFAULT_SETTINGS.PROVIDER_TOKENS,
};
const MOCK_USER_PREFERENCES: {
settings: ApiSettings | PostApiSettings | null;
} = {
settings: null,
};
const conversations: Conversation[] = [
{
conversation_id: "1",
@@ -79,7 +104,6 @@ const openHandsHandlers = [
export const handlers = [
...STRIPE_BILLING_HANDLERS,
...SETTINGS_HANDLERS,
...FILE_SERVICE_HANDLERS,
...openHandsHandlers,
http.get("/api/user/repositories", () =>
@@ -122,6 +146,40 @@ export const handlers = [
return HttpResponse.json(config);
}),
http.get("/api/settings", async () => {
await delay();
const { settings } = MOCK_USER_PREFERENCES;
if (!settings) return HttpResponse.json(null, { status: 404 });
if (Object.keys(settings.provider_tokens_set).length > 0)
settings.provider_tokens_set = { github: false, gitlab: false };
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
const body = await request.json();
if (body) {
let newSettings: Partial<PostApiSettings> = {};
if (typeof body === "object") {
newSettings = { ...body };
}
const fullSettings = {
...MOCK_DEFAULT_USER_SETTINGS,
...MOCK_USER_PREFERENCES.settings,
...newSettings,
};
MOCK_USER_PREFERENCES.settings = fullSettings;
return HttpResponse.json(null, { status: 200 });
}
return HttpResponse.json(null, { status: 400 });
}),
http.post("/api/authenticate", async () =>
HttpResponse.json({ message: "Authenticated" }),
),
@@ -202,4 +260,10 @@ export const handlers = [
}),
http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })),
http.post("/api/reset-settings", async () => {
await delay();
MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS };
return HttpResponse.json(null, { status: 200 });
}),
];

View File

@@ -1,52 +0,0 @@
import { delay, http, HttpResponse } from "msw";
import { UserSettings } from "#/api/settings-service/settings-service.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
const MOCK_USER_PREFERENCES: {
settings: UserSettings | null;
} = {
settings: null,
};
export const SETTINGS_HANDLERS = [
http.get("/api/settings", async () => {
await delay();
const { settings } = MOCK_USER_PREFERENCES;
if (!settings) return HttpResponse.json(null, { status: 404 });
if (Object.keys(settings.provider_tokens_set).length > 0)
settings.github_token_is_set = true;
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
const body = await request.json();
if (body) {
let newSettings: Partial<UserSettings> = {};
if (typeof body === "object") {
newSettings = { ...body };
}
const fullSettings: UserSettings = {
...DEFAULT_SETTINGS,
...MOCK_USER_PREFERENCES.settings,
...newSettings,
};
MOCK_USER_PREFERENCES.settings = fullSettings;
return HttpResponse.json(null, { status: 200 });
}
return HttpResponse.json(null, { status: 400 });
}),
http.post("/api/settings/reset", async () => {
await delay();
MOCK_USER_PREFERENCES.settings = { ...DEFAULT_SETTINGS };
return HttpResponse.json(null, { status: 200 });
}),
];

View File

@@ -27,7 +27,6 @@ import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ServerUserSettings } from "#/api/settings-service/settings-service.types";
import { ProviderOptions } from "#/types/settings";
import { useAuth } from "#/context/auth-context";
@@ -68,10 +67,9 @@ function AccountSettings() {
if (isSuccess) {
return (
isCustomModel(resources.models, settings.llm_model) ||
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet({
...settings,
provider_tokens_set: settings.provider_tokens_set || {},
})
);
}
@@ -80,12 +78,12 @@ function AccountSettings() {
};
const hasAppSlug = !!config?.APP_SLUG;
const isLLMKeySet = settings?.llm_api_key_set === "**********";
const isAnalyticsEnabled = settings?.user_consents_to_analytics;
const isGitHubTokenSet =
providerTokensSet.includes(ProviderOptions.github) || false;
const isGitLabTokenSet =
providerTokensSet.includes(ProviderOptions.gitlab) || false;
const isLLMKeySet = settings?.LLM_API_KEY_SET;
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
const modelsAndProviders = organizeModelsAndProviders(
@@ -96,7 +94,7 @@ function AccountSettings() {
"basic" | "advanced"
>(isAdvancedSettingsSet ? "advanced" : "basic");
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
React.useState(!!settings?.security_analyzer);
React.useState(!!settings?.SECURITY_ANALYZER);
const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] =
React.useState(false);
@@ -119,10 +117,6 @@ function AccountSettings() {
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
({ label }) => label === rawRemoteRuntimeResourceFactor,
)?.key;
const remoteRuntimeResourceFactorValue = parseInt(
remoteRuntimeResourceFactor || "",
10,
);
const userConsentsToAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
@@ -148,7 +142,7 @@ function AccountSettings() {
: llmBaseUrl;
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
const newSettings: Partial<ServerUserSettings> = {
const newSettings = {
provider_tokens:
githubToken || gitlabToken
? {
@@ -156,20 +150,21 @@ function AccountSettings() {
gitlab: gitlabToken || "",
}
: undefined,
language: languageValue,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
enable_default_condenser: enableMemoryCondenser,
enable_sound_notifications: enableSoundNotifications,
llm_model: finalLlmModel,
llm_base_url: finalLlmBaseUrl,
llm_api_key_set: finalLlmApiKey,
agent: formData.get("agent-input")?.toString(),
security_analyzer:
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
LLM_MODEL: finalLlmModel,
LLM_BASE_URL: finalLlmBaseUrl,
llm_api_key: finalLlmApiKey,
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
remote_runtime_resource_factor:
remoteRuntimeResourceFactorValue ||
DEFAULT_SETTINGS.remote_runtime_resource_factor,
confirmation_mode: confirmationModeIsEnabled,
REMOTE_RUNTIME_RESOURCE_FACTOR:
remoteRuntimeResourceFactor !== null
? Number(remoteRuntimeResourceFactor)
: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
CONFIRMATION_MODE: confirmationModeIsEnabled,
};
saveSettings(newSettings, {
@@ -210,7 +205,7 @@ function AccountSettings() {
setLlmConfigMode(isToggled ? "advanced" : "basic");
if (!isToggled) {
// reset advanced state
setConfirmationModeIsEnabled(!!settings?.security_analyzer);
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
}
};
@@ -254,7 +249,7 @@ function AccountSettings() {
{llmConfigMode === "basic" && !shouldHandleSpecialSaasCase && (
<ModelSelector
models={modelsAndProviders}
currentModel={settings.llm_model}
currentModel={settings.LLM_MODEL}
/>
)}
@@ -263,7 +258,7 @@ function AccountSettings() {
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={settings.llm_model}
defaultValue={settings.LLM_MODEL}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
@@ -274,7 +269,7 @@ function AccountSettings() {
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={settings.llm_base_url}
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
@@ -315,7 +310,7 @@ function AccountSettings() {
label: agent,
})) || []
}
defaultSelectedKey={settings.agent}
defaultSelectedKey={settings.AGENT}
isClearable={false}
/>
)}
@@ -334,7 +329,7 @@ function AccountSettings() {
</>
}
items={REMOTE_RUNTIME_OPTIONS}
defaultSelectedKey={settings.remote_runtime_resource_factor?.toString()}
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
isDisabled
isClearable={false}
/>
@@ -344,7 +339,7 @@ function AccountSettings() {
<SettingsSwitch
testId="enable-confirmation-mode-switch"
onToggle={setConfirmationModeIsEnabled}
defaultIsToggled={!!settings.confirmation_mode}
defaultIsToggled={!!settings.CONFIRMATION_MODE}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
@@ -355,7 +350,7 @@ function AccountSettings() {
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
defaultIsToggled={!!settings.enable_default_condenser}
defaultIsToggled={!!settings.ENABLE_DEFAULT_CONDENSER}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
</SettingsSwitch>
@@ -373,7 +368,7 @@ function AccountSettings() {
label: analyzer,
})) || []
}
defaultSelectedKey={settings.security_analyzer}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
/>
@@ -506,7 +501,7 @@ function AccountSettings() {
key: language.value,
label: language.label,
}))}
defaultSelectedKey={settings.language}
defaultSelectedKey={settings.LANGUAGE}
isClearable={false}
/>
@@ -521,7 +516,7 @@ function AccountSettings() {
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
defaultIsToggled={!!settings.enable_sound_notifications}
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>

View File

@@ -198,13 +198,13 @@ function AppContent() {
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings?.security_analyzer}
showSecurityLock={!!settings?.SECURITY_ANALYZER}
/>
{settings && (
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.security_analyzer}
securityAnalyzer={settings.SECURITY_ANALYZER}
/>
)}
</div>

View File

@@ -14,10 +14,9 @@ import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useConfig } from "#/hooks/query/use-config";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
import { AuthModal } from "#/components/features/waitlist/auth-modal";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import { useSettings } from "#/hooks/query/use-settings";
import { useAuth } from "#/context/auth-context";
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
import { useBalance } from "#/hooks/query/use-balance";
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
@@ -60,8 +59,7 @@ export default function MainApp() {
const navigate = useNavigate();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const { data: settings, isFetched: settingsIsFetched } = useSettings();
const { providersAreSet } = useAuth();
const { data: settings } = useSettings();
const { error, isFetching } = useBalance();
const { migrateUserConsent } = useMigrateUserConsent();
const { t } = useTranslation();
@@ -81,17 +79,17 @@ export default function MainApp() {
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false);
React.useEffect(() => {
if (settingsIsFetched && settings?.language) {
i18n.changeLanguage(settings.language);
if (settings?.LANGUAGE) {
i18n.changeLanguage(settings.LANGUAGE);
}
}, [settingsIsFetched, settings?.language]);
}, [settings?.LANGUAGE]);
React.useEffect(() => {
const consentFormModalIsOpen =
settingsIsFetched && settings?.user_consents_to_analytics === null;
settings?.USER_CONSENTS_TO_ANALYTICS === null;
setConsentFormIsOpen(consentFormModalIsOpen);
}, [settingsIsFetched, settings?.user_consents_to_analytics]);
}, [settings]);
React.useEffect(() => {
// Migrate user consent to the server if it was previously stored in localStorage
@@ -114,7 +112,7 @@ export default function MainApp() {
}, [error?.status, pathname, isFetching]);
const userIsAuthed = !!isAuthed && !authError;
const renderWaitlistModal =
const renderAuthModal =
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
return (
@@ -131,13 +129,7 @@ export default function MainApp() {
<Outlet />
</div>
{renderWaitlistModal && (
<WaitlistModal
ghTokenIsSet={providersAreSet}
githubAuthUrl={gitHubAuthUrl}
/>
)}
{renderAuthModal && <AuthModal githubAuthUrl={gitHubAuthUrl} />}
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (
<AnalyticsConsentFormModal
onClose={() => {
@@ -148,7 +140,7 @@ export default function MainApp() {
{config.data?.FEATURE_FLAGS.ENABLE_BILLING &&
config.data?.APP_MODE === "saas" &&
settings?.is_new_user && <SetupPaymentModal />}
settings?.IS_NEW_USER && <SetupPaymentModal />}
</div>
);
}

View File

@@ -1,28 +1,28 @@
import { ClientUserSettings } from "#/api/settings-service/settings-service.types";
import { Settings } from "#/types/settings";
export const LATEST_SETTINGS_VERSION = 5;
export const DEFAULT_SETTINGS: ClientUserSettings = {
llm_model: "anthropic/claude-3-5-sonnet-20241022",
llm_base_url: "",
agent: "CodeActAgent",
language: "en",
llm_api_key_set: null,
confirmation_mode: false,
security_analyzer: "",
remote_runtime_resource_factor: 1,
github_token_is_set: false,
enable_default_condenser: true,
enable_sound_notifications: false,
user_consents_to_analytics: false,
provider_tokens_set: {
github: false,
gitlab: false,
export const DEFAULT_SETTINGS: Settings = {
LLM_MODEL: "anthropic/claude-3-5-sonnet-20241022",
LLM_BASE_URL: "",
AGENT: "CodeActAgent",
LANGUAGE: "en",
LLM_API_KEY_SET: false,
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
PROVIDER_TOKENS_SET: { github: false, gitlab: false },
ENABLE_DEFAULT_CONDENSER: true,
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,
PROVIDER_TOKENS: {
github: "",
gitlab: "",
},
is_new_user: true,
IS_NEW_USER: true,
};
/**
* Get the default settings
*/
export const getDefaultSettings = () => DEFAULT_SETTINGS;
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;

38
frontend/src/types/git.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
import { Provider } from "#/types/settings";
interface GitHubErrorReponse {
message: string;
documentation_url: string;
status: number;
}
interface GitUser {
id: number;
login: string;
avatar_url: string;
company: string | null;
name: string | null;
email: string | null;
}
interface GitRepository {
id: number;
full_name: string;
git_provider: Provider;
stargazers_count?: number;
link_header?: string;
}
interface GitHubCommit {
html_url: string;
sha: string;
commit: {
author: {
date: string; // ISO 8601
};
};
}
interface GithubAppInstallation {
installations: { id: number }[];
}

View File

@@ -1,24 +0,0 @@
import { GitProvider } from "#/api/settings-service/settings-service.types";
export interface GitHubErrorReponse {
message: string;
documentation_url: string;
status: number;
}
export interface GitUser {
id: number;
login: string;
avatar_url: string;
company: string | null;
name: string | null;
email: string | null;
}
export interface GitRepository {
id: number;
full_name: string;
git_provider: GitProvider;
stargazers_count?: number;
link_header?: string;
}

View File

@@ -1,10 +1,10 @@
import { UserSettings } from "#/api/settings-service/settings-service.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
export const hasAdvancedSettingsSet = (settings: UserSettings): boolean =>
!!settings.llm_base_url ||
settings.agent !== DEFAULT_SETTINGS.agent ||
settings.remote_runtime_resource_factor !==
DEFAULT_SETTINGS.remote_runtime_resource_factor ||
settings.confirmation_mode ||
!!settings.security_analyzer;
export const hasAdvancedSettingsSet = (settings: Partial<Settings>): boolean =>
!!settings.LLM_BASE_URL ||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
settings.REMOTE_RUNTIME_RESOURCE_FACTOR !==
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR ||
settings.CONFIRMATION_MODE ||
!!settings.SECURITY_ANALYZER;

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import { UserSettings } from "#/api/settings-service/settings-service.types";
import { Settings } from "#/types/settings";
const extractBasicFormData = (formData: FormData) => {
const provider = formData.get("llm-provider-input")?.toString();
@@ -47,7 +47,9 @@ const extractAdvancedFormData = (formData: FormData) => {
};
};
export const extractSettings = (formData: FormData): Partial<UserSettings> => {
export const extractSettings = (
formData: FormData,
): Partial<Settings> & { llm_api_key?: string | null } => {
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
extractBasicFormData(formData);
@@ -72,14 +74,15 @@ export const extractSettings = (formData: FormData): Partial<UserSettings> => {
}
return {
llm_model: CUSTOM_LLM_MODEL || LLM_MODEL,
llm_api_key_set: LLM_API_KEY,
agent: AGENT,
language: LANGUAGE,
llm_base_url: LLM_BASE_URL,
confirmation_mode: CONFIRMATION_MODE,
security_analyzer: SECURITY_ANALYZER,
enable_default_condenser: ENABLE_DEFAULT_CONDENSER,
provider_tokens_set: providerTokens,
LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL,
LLM_API_KEY_SET: !!LLM_API_KEY,
AGENT,
LANGUAGE,
LLM_BASE_URL,
CONFIRMATION_MODE,
SECURITY_ANALYZER,
ENABLE_DEFAULT_CONDENSER,
PROVIDER_TOKENS: providerTokens,
llm_api_key: LLM_API_KEY,
};
};

View File

@@ -43,7 +43,7 @@ from openhands.io import read_task
prompt_session = PromptSession()
def display_message(message: str):
def display_message(message: str) -> None:
print_formatted_text(
FormattedText(
[
@@ -55,7 +55,7 @@ def display_message(message: str):
)
def display_command(command: str):
def display_command(command: str) -> None:
print_formatted_text(
FormattedText(
[
@@ -67,7 +67,7 @@ def display_command(command: str):
)
def display_confirmation(confirmation_state: ActionConfirmationStatus):
def display_confirmation(confirmation_state: ActionConfirmationStatus) -> None:
if confirmation_state == ActionConfirmationStatus.CONFIRMED:
print_formatted_text(
FormattedText(
@@ -100,7 +100,7 @@ def display_confirmation(confirmation_state: ActionConfirmationStatus):
)
def display_command_output(output: str):
def display_command_output(output: str) -> None:
lines = output.split('\n')
for line in lines:
if line.startswith('[Python Interpreter') or line.startswith('openhands@'):
@@ -110,7 +110,7 @@ def display_command_output(output: str):
print_formatted_text('')
def display_file_edit(event: FileEditAction | FileEditObservation):
def display_file_edit(event: FileEditAction | FileEditObservation) -> None:
print_formatted_text(
FormattedText(
[
@@ -121,7 +121,7 @@ def display_file_edit(event: FileEditAction | FileEditObservation):
)
def display_event(event: Event, config: AppConfig):
def display_event(event: Event, config: AppConfig) -> None:
if isinstance(event, Action):
if hasattr(event, 'thought'):
display_message(event.thought)
@@ -175,7 +175,7 @@ async def read_confirmation_input():
return False
async def main(loop: asyncio.AbstractEventLoop):
async def main(loop: asyncio.AbstractEventLoop) -> None:
"""Runs the agent in CLI mode."""
args = parse_arguments()
@@ -207,7 +207,7 @@ async def main(loop: asyncio.AbstractEventLoop):
event_stream = runtime.event_stream
async def prompt_for_next_task():
async def prompt_for_next_task() -> None:
next_message = await read_prompt_input(config.cli_multiline_input)
if not next_message.strip():
await prompt_for_next_task()
@@ -219,7 +219,7 @@ async def main(loop: asyncio.AbstractEventLoop):
action = MessageAction(content=next_message)
event_stream.add_event(action, EventSource.USER)
async def on_event_async(event: Event):
async def on_event_async(event: Event) -> None:
display_event(event, config)
if isinstance(event, AgentStateChangedObservation):
if event.agent_state in [

View File

@@ -1,4 +1,4 @@
from typing import ClassVar
from typing import Any, ClassVar
from pydantic import BaseModel, Field, SecretStr
@@ -50,7 +50,7 @@ class AppConfig(BaseModel):
"""
llms: dict[str, LLMConfig] = Field(default_factory=dict)
agents: dict = Field(default_factory=dict)
agents: dict[str, AgentConfig] = Field(default_factory=dict)
default_agent: str = Field(default=OH_DEFAULT_AGENT)
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
@@ -93,7 +93,7 @@ class AppConfig(BaseModel):
model_config = {'extra': 'forbid'}
def get_llm_config(self, name='llm') -> LLMConfig:
def get_llm_config(self, name: str = 'llm') -> LLMConfig:
"""'llm' is the name for default config (for backward compatibility prior to 0.8)."""
if name in self.llms:
return self.llms[name]
@@ -105,10 +105,10 @@ class AppConfig(BaseModel):
self.llms['llm'] = LLMConfig()
return self.llms['llm']
def set_llm_config(self, value: LLMConfig, name='llm') -> None:
def set_llm_config(self, value: LLMConfig, name: str = 'llm') -> None:
self.llms[name] = value
def get_agent_config(self, name='agent') -> AgentConfig:
def get_agent_config(self, name: str = 'agent') -> AgentConfig:
"""'agent' is the name for default config (for backward compatibility prior to 0.8)."""
if name in self.agents:
return self.agents[name]
@@ -116,22 +116,24 @@ class AppConfig(BaseModel):
self.agents['agent'] = AgentConfig()
return self.agents['agent']
def set_agent_config(self, value: AgentConfig, name='agent') -> None:
def set_agent_config(self, value: AgentConfig, name: str = 'agent') -> None:
self.agents[name] = value
def get_agent_to_llm_config_map(self) -> dict[str, LLMConfig]:
"""Get a map of agent names to llm configs."""
return {name: self.get_llm_config_from_agent(name) for name in self.agents}
def get_llm_config_from_agent(self, name='agent') -> LLMConfig:
def get_llm_config_from_agent(self, name: str = 'agent') -> LLMConfig:
agent_config: AgentConfig = self.get_agent_config(name)
llm_config_name = agent_config.llm_config
llm_config_name = (
agent_config.llm_config if agent_config.llm_config is not None else 'llm'
)
return self.get_llm_config(llm_config_name)
def get_agent_configs(self) -> dict[str, AgentConfig]:
return self.agents
def model_post_init(self, __context):
def model_post_init(self, __context: Any) -> None:
"""Post-initialization hook, called when the instance is created with only default values."""
super().model_post_init(__context)
if not AppConfig.defaults_dict: # Only set defaults_dict if it's empty

View File

@@ -151,7 +151,7 @@ class LLMConfig(BaseModel):
return llm_mapping
def model_post_init(self, __context: Any):
def model_post_init(self, __context: Any) -> None:
"""Post-initialization hook to assign OpenRouter-related variables to environment variables.
This ensures that these values are accessible to litellm at runtime.

View File

@@ -58,7 +58,7 @@ def load_from_env(
return None
# helper function to set attributes based on env vars
def set_attr_from_env(sub_config: BaseModel, prefix='') -> None:
def set_attr_from_env(sub_config: BaseModel, prefix: str = '') -> None:
"""Set attributes of a config model based on environment variables."""
for field_name, field_info in sub_config.model_fields.items():
field_value = getattr(sub_config, field_name)
@@ -275,7 +275,7 @@ def get_or_create_jwt_secret(file_store: FileStore) -> str:
return new_secret
def finalize_config(cfg: AppConfig):
def finalize_config(cfg: AppConfig) -> None:
"""More tweaks to the config after it's been loaded."""
if cfg.workspace_base is not None:
cfg.workspace_base = os.path.abspath(cfg.workspace_base)

View File

@@ -6,7 +6,7 @@ import sys
import traceback
from datetime import datetime
from types import TracebackType
from typing import Any, Literal, Mapping, TextIO
from typing import Any, Literal, Mapping, MutableMapping, TextIO
import litellm
from pythonjsonlogger.json import JsonFormatter
@@ -304,7 +304,7 @@ def get_file_handler(
return file_handler
def json_formatter():
def json_formatter() -> JsonFormatter:
return JsonFormatter(
'{message}{levelname}',
style='{',
@@ -471,11 +471,15 @@ llm_response_logger = _setup_llm_logger('response', current_log_level)
class OpenHandsLoggerAdapter(logging.LoggerAdapter):
extra: dict
def __init__(self, logger=openhands_logger, extra=None):
def __init__(
self, logger: logging.Logger = openhands_logger, extra: dict | None = None
) -> None:
self.logger = logger
self.extra = extra or {}
def process(self, msg, kwargs):
def process(
self, msg: str, kwargs: MutableMapping[str, Any]
) -> tuple[str, MutableMapping[str, Any]]:
"""
If 'extra' is supplied in kwargs, merge it with the adapters 'extra' dict
Starting in Python 3.13, LoggerAdapter's merge_extra option will do this.

View File

@@ -12,14 +12,14 @@ async def run_agent_until_done(
runtime: Runtime,
memory: Memory,
end_states: list[AgentState],
):
) -> None:
"""
run_agent_until_done takes a controller and a runtime, and will run
the agent until it reaches a terminal state.
Note that runtime must be connected before being passed in here.
"""
def status_callback(msg_type, msg_id, msg):
def status_callback(msg_type: str, msg_id: str, msg: str) -> None:
if msg_type == 'error':
logger.error(msg)
if controller:

View File

@@ -163,7 +163,7 @@ async def run_controller(
# init with the provided actions
event_stream.add_event(initial_user_action, EventSource.USER)
def on_event(event: Event):
def on_event(event: Event) -> None:
if isinstance(event, AgentStateChangedObservation):
if event.agent_state == AgentState.AWAITING_USER_INPUT:
if exit_on_message:

View File

@@ -1,5 +1,5 @@
from enum import Enum
from typing import Literal
from typing import Any, Literal
from litellm import ChatCompletionMessageToolCall
from pydantic import BaseModel, Field, model_serializer
@@ -14,7 +14,7 @@ class Content(BaseModel):
type: str
cache_prompt: bool = False
@model_serializer
@model_serializer(mode='plain')
def serialize_model(
self,
) -> dict[str, str | dict[str, str]] | list[dict[str, str | dict[str, str]]]:
@@ -25,7 +25,7 @@ class TextContent(Content):
type: str = ContentType.TEXT.value
text: str
@model_serializer
@model_serializer(mode='plain')
def serialize_model(self) -> dict[str, str | dict[str, str]]:
data: dict[str, str | dict[str, str]] = {
'type': self.type,
@@ -40,7 +40,7 @@ class ImageContent(Content):
type: str = ContentType.IMAGE_URL.value
image_urls: list[str]
@model_serializer
@model_serializer(mode='plain')
def serialize_model(self) -> list[dict[str, str | dict[str, str]]]:
images: list[dict[str, str | dict[str, str]]] = []
for url in self.image_urls:
@@ -71,8 +71,8 @@ class Message(BaseModel):
def contains_image(self) -> bool:
return any(isinstance(content, ImageContent) for content in self.content)
@model_serializer
def serialize_model(self) -> dict:
@model_serializer(mode='plain')
def serialize_model(self) -> dict[str, Any]:
# We need two kinds of serializations:
# - into a single string: for providers that don't support list of content items (e.g. no vision, no tool calls)
# - into a list of content items: the new APIs of providers with vision/prompt caching/tool calls
@@ -84,18 +84,18 @@ class Message(BaseModel):
# some providers, like HF and Groq/llama, don't support a list here, but a single string
return self._string_serializer()
def _string_serializer(self) -> dict:
def _string_serializer(self) -> dict[str, Any]:
# convert content to a single string
content = '\n'.join(
item.text for item in self.content if isinstance(item, TextContent)
)
message_dict: dict = {'content': content, 'role': self.role}
message_dict: dict[str, Any] = {'content': content, 'role': self.role}
# add tool call keys if we have a tool call or response
return self._add_tool_call_keys(message_dict)
def _list_serializer(self) -> dict:
content: list[dict] = []
def _list_serializer(self) -> dict[str, Any]:
content: list[dict[str, Any]] = []
role_tool_with_prompt_caching = False
for item in self.content:
d = item.model_dump()
@@ -120,7 +120,7 @@ class Message(BaseModel):
# We know d is a list for ImageContent
content.extend([d] if isinstance(d, dict) else d)
message_dict: dict = {'content': content, 'role': self.role}
message_dict: dict[str, Any] = {'content': content, 'role': self.role}
if role_tool_with_prompt_caching:
message_dict['cache_control'] = {'type': 'ephemeral'}
@@ -128,7 +128,7 @@ class Message(BaseModel):
# add tool call keys if we have a tool call or response
return self._add_tool_call_keys(message_dict)
def _add_tool_call_keys(self, message_dict: dict) -> dict:
def _add_tool_call_keys(self, message_dict: dict[str, Any]) -> dict[str, Any]:
"""Add tool call keys if we have a tool call or response.
NOTE: this is necessary for both native and non-native tool calling

View File

@@ -23,9 +23,11 @@ class CriticResult(BaseModel):
class BaseCritic(abc.ABC):
"""
A critic is a function that takes in a list of events and returns a score about the quality of those events.
A critic is a function that takes in a list of events, optional git patch, and returns a score about the quality of those events.
"""
@abc.abstractmethod
def evaluate(self, events: list[Event]) -> CriticResult:
def evaluate(
self, events: list[Event], git_patch: str | None = None
) -> CriticResult:
pass

View File

@@ -5,16 +5,21 @@ from openhands.events.action import Action, AgentFinishAction
class AgentFinishedCritic(BaseCritic):
"""This is a simple rule-based critic that checks if the last event is an AgentFinishAction.
If not, it will return a score of 0 and a message indicating that the agent did not finish.
If the git patch is provided and is empty, it will return a score of 0 and a message indicating that the git patch is empty.
"""
def __init__(self):
pass
def evaluate(self, events: list[Event]) -> CriticResult:
def evaluate(
self, events: list[Event], git_patch: str | None = None
) -> CriticResult:
last_action = next((h for h in reversed(events) if isinstance(h, Action)), None)
if git_patch is not None and len(git_patch.strip()) == 0:
return CriticResult(score=0, message='Git patch is empty.')
if isinstance(last_action, AgentFinishAction):
return CriticResult(score=1, message='Agent finished.')
else:

View File

@@ -328,7 +328,7 @@ def main() -> None:
runtime_container_image = my_args.runtime_container_image
if runtime_container_image is None:
runtime_container_image = 'ghcr.io/all-hands-ai/runtime:0.31.0-nikolaik'
runtime_container_image = 'ghcr.io/all-hands-ai/runtime:0.32.0-nikolaik'
owner, repo = my_args.selected_repo.split('/')
token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')

View File

@@ -1,4 +1,3 @@
import json
import logging
import multiprocessing as mp
import os
@@ -7,7 +6,6 @@ from enum import Enum
from typing import Callable
import httpx
import pandas as pd
from openhands.controller.state.state import State
from openhands.core.logger import get_console_handler
@@ -134,43 +132,6 @@ def cleanup() -> None:
process.join()
def prepare_dataset(
dataset: pd.DataFrame, output_file: str, eval_n_limit: int
) -> pd.DataFrame:
assert 'instance_id' in dataset.columns, (
"Expected 'instance_id' column in the dataset. You should define your own "
"unique identifier for each instance and use it as the 'instance_id' column."
)
id_column = 'instance_id'
logger.info(f'Writing evaluation output to {output_file}')
finished_ids = set()
if os.path.exists(output_file):
with open(output_file, 'r') as f:
for line in f:
data = json.loads(line)
finished_ids.add(data[id_column])
logger.warning(
f'Output file {output_file} already exists. Loaded '
f'{len(finished_ids)} finished instances.'
)
if eval_n_limit:
dataset = dataset.head(eval_n_limit)
logger.info(f'Limiting evaluation to first {eval_n_limit} instances.')
new_dataset = [
instance
for _, instance in dataset.iterrows()
if instance[id_column] not in finished_ids
]
logger.info(
f'Finished instances: {len(finished_ids)}, '
f'Remaining instances: {len(new_dataset)}'
)
return pd.DataFrame(new_dataset)
def reset_logger_for_multiprocessing(
logger: logging.Logger, instance_id: str, log_dir: str
) -> None:

View File

@@ -82,7 +82,7 @@ async def unset_settings_tokens(request: Request) -> JSONResponse:
)
@app.post('/settings/reset', response_model=dict[str, str])
@app.post('/reset-settings', response_model=dict[str, str])
async def reset_settings(request: Request) -> JSONResponse:
"""
Resets user settings.

View File

@@ -167,8 +167,9 @@ class Session:
"""
Initialize LLM, extracted for testing.
"""
agent_name = agent_cls if agent_cls is not None else 'agent'
return LLM(
config=self.config.get_llm_config_from_agent(agent_cls),
config=self.config.get_llm_config_from_agent(agent_name),
retry_listener=self._notify_on_llm_retry,
)

45
poetry.lock generated
View File

@@ -1191,7 +1191,7 @@ version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
groups = ["main", "runtime"]
groups = ["main", "runtime", "test"]
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
@@ -1261,6 +1261,7 @@ files = [
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
]
markers = {test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
[package.dependencies]
pycparser = "*"
@@ -1483,7 +1484,7 @@ version = "1.3.1"
description = "Python library for calculating contours of 2D quadrilateral grids"
optional = false
python-versions = ">=3.10"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "contourpy-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a045f341a77b77e1c5de31e74e966537bba9f3c4099b35bf4c2e3939dd54cdab"},
{file = "contourpy-1.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:500360b77259914f7805af7462e41f9cb7ca92ad38e9f94d6c8641b089338124"},
@@ -1691,7 +1692,7 @@ version = "0.12.1"
description = "Composable style cycles"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"},
{file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"},
@@ -1723,7 +1724,7 @@ version = "3.0.1"
description = "HuggingFace community-driven open-source library of datasets"
optional = false
python-versions = ">=3.8.0"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "datasets-3.0.1-py3-none-any.whl", hash = "sha256:db080aab41c8cc68645117a0f172e5c6789cbc672f066de0aa5a08fc3eebc686"},
{file = "datasets-3.0.1.tar.gz", hash = "sha256:40d63b09e76a3066c32e746d6fdc36fd3f29ed2acd49bf5b1a2100da32936511"},
@@ -1887,7 +1888,7 @@ version = "0.3.8"
description = "serialize all of Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"},
{file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"},
@@ -2235,7 +2236,7 @@ version = "4.56.0"
description = "Tools to manipulate font files"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "fonttools-4.56.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:331954d002dbf5e704c7f3756028e21db07097c19722569983ba4d74df014000"},
{file = "fonttools-4.56.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8d1613abd5af2f93c05867b3a3759a56e8bf97eb79b1da76b2bc10892f96ff16"},
@@ -2513,7 +2514,7 @@ version = "24.11.1"
description = "Coroutine-based network library"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["test"]
files = [
{file = "gevent-24.11.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:92fe5dfee4e671c74ffaa431fd7ffd0ebb4b339363d24d0d944de532409b935e"},
{file = "gevent-24.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7bfcfe08d038e1fa6de458891bca65c1ada6d145474274285822896a858c870"},
@@ -2661,7 +2662,7 @@ grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_versi
grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
requests = ">=2.18.0,<3.0.0.dev0"
@@ -2874,7 +2875,7 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0dev"
proto-plus = [
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
@@ -3010,7 +3011,7 @@ version = "3.1.1"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.7"
groups = ["main", "evaluation"]
groups = ["main", "evaluation", "test"]
files = [
{file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"},
{file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"},
@@ -3086,6 +3087,7 @@ files = [
{file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"},
{file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"},
]
markers = {test = "platform_python_implementation == \"CPython\""}
[package.extras]
docs = ["Sphinx", "furo"]
@@ -4133,7 +4135,7 @@ version = "1.4.8"
description = "A fast implementation of the Cassowary constraint solver"
optional = false
python-versions = ">=3.10"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "kiwisolver-1.4.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88c6f252f6816a73b1f8c904f7bbe02fd67c09a69f7cb8a0eecdbf5ce78e63db"},
{file = "kiwisolver-1.4.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72941acb7b67138f35b879bbe85be0f6c6a70cab78fe3ef6db9c024d9223e5b"},
@@ -4717,7 +4719,7 @@ version = "3.10.1"
description = "Python plotting package"
optional = false
python-versions = ">=3.10"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "matplotlib-3.10.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ff2ae14910be903f4a24afdbb6d7d3a6c44da210fc7d42790b87aeac92238a16"},
{file = "matplotlib-3.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0721a3fd3d5756ed593220a8b86808a36c5031fce489adb5b31ee6dbb47dd5b2"},
@@ -5008,7 +5010,7 @@ version = "0.70.16"
description = "better multiprocessing and multithreading in Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "multiprocess-0.70.16-pp310-pypy310_pp73-macosx_10_13_x86_64.whl", hash = "sha256:476887be10e2f59ff183c006af746cb6f1fd0eadcfd4ef49e605cbe2659920ee"},
{file = "multiprocess-0.70.16-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d951bed82c8f73929ac82c61f01a7b5ce8f3e5ef40f5b52553b4f547ce2b08ec"},
@@ -6325,7 +6327,7 @@ version = "19.0.1"
description = "Python library for Apache Arrow"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:fc28912a2dc924dddc2087679cc8b7263accc71b9ff025a1362b004711661a69"},
{file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fca15aabbe9b8355800d923cc2e82c8ef514af321e18b437c3d782aa884eaeec"},
@@ -6434,11 +6436,12 @@ version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "runtime"]
groups = ["main", "runtime", "test"]
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
markers = {test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
[[package]]
name = "pycryptodome"
@@ -8166,7 +8169,7 @@ version = "0.13.2"
description = "Statistical data visualization"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987"},
{file = "seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7"},
@@ -8205,7 +8208,7 @@ version = "75.8.2"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation", "runtime"]
groups = ["main", "evaluation", "runtime", "test"]
files = [
{file = "setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f"},
{file = "setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2"},
@@ -9872,7 +9875,7 @@ version = "3.5.0"
description = "Python binding for xxHash"
optional = false
python-versions = ">=3.7"
groups = ["main", "evaluation"]
groups = ["evaluation"]
files = [
{file = "xxhash-3.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ece616532c499ee9afbb83078b1b952beffef121d989841f7f4b3dc5ac0fd212"},
{file = "xxhash-3.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3171f693dbc2cef6477054a665dc255d996646b4023fe56cb4db80e26f4cc520"},
@@ -10122,7 +10125,7 @@ version = "5.0"
description = "Very basic event publishing system"
optional = false
python-versions = ">=3.7"
groups = ["main"]
groups = ["test"]
files = [
{file = "zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26"},
{file = "zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd"},
@@ -10141,7 +10144,7 @@ version = "7.2"
description = "Interfaces for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "zope.interface-7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2"},
{file = "zope.interface-7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a"},
@@ -10193,4 +10196,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "bd1e164559f6395718bc76482493aa71327e7aee9ebe6131facba65661d9abec"
content-hash = "4081b88d1b970aa56603359e41430d4465486b3866bfd50371d5c4f77fb58fb4"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.31.0"
version = "0.32.0"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -14,8 +14,6 @@ packages = [
[tool.poetry.dependencies]
python = "^3.12"
datasets = "*"
pandas = "*"
litellm = "^1.60.0"
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
@@ -23,7 +21,6 @@ google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
termcolor = "*"
seaborn = "*"
docker = "*"
fastapi = "*"
toml = "*"
@@ -39,8 +36,6 @@ jinja2 = "^3.1.3"
python-multipart = "*"
boto3 = "*"
minio = "^7.2.8"
gevent = "^24.2.1"
pyarrow = "19.0.1" # transitive dependency, pinned here to avoid conflicts
tenacity = ">=8.5,<10.0"
zope-interface = "7.2"
pathspec = "^0.12.1"
@@ -96,11 +91,11 @@ pytest-xdist = "*"
openai = "*"
pandas = "*"
reportlab = "*"
gevent = "^24.2.1"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -129,7 +124,6 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"
@@ -149,6 +143,8 @@ browsergym-webarena = "0.13.3"
browsergym-miniwob = "0.13.3"
browsergym-visualwebarena = "0.13.3"
boto3-stubs = {extras = ["s3"], version = "^1.37.19"}
pyarrow = "19.0.1" # transitive dependency, pinned here to avoid conflicts
datasets = "*"
[tool.poetry-dynamic-versioning]
enable = true