mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
46 Commits
0.24.0
...
xw/sysbox-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
264d24f750 | ||
|
|
1a7003a705 | ||
|
|
8d097efb4f | ||
|
|
2e98fc8fb3 | ||
|
|
e3e00ed70a | ||
|
|
96d1992823 | ||
|
|
7a3a0d8c0c | ||
|
|
fdffca18e0 | ||
|
|
b10416e0a3 | ||
|
|
1f462d2417 | ||
|
|
0a6ff463db | ||
|
|
9ff15bf94f | ||
|
|
6c48013601 | ||
|
|
07fcb786af | ||
|
|
ce42e22105 | ||
|
|
14ee6d7afe | ||
|
|
57391d6e66 | ||
|
|
a7bb73ded2 | ||
|
|
f4b123f73b | ||
|
|
ae31a24c29 | ||
|
|
3a478c2303 | ||
|
|
82b5325792 | ||
|
|
265e8ae5f4 | ||
|
|
0cbf50576d | ||
|
|
745038b394 | ||
|
|
b018567d53 | ||
|
|
30e39e85d0 | ||
|
|
4443417c75 | ||
|
|
efbff2e655 | ||
|
|
63565982aa | ||
|
|
b07fddcb71 | ||
|
|
99b50d038e | ||
|
|
1ddfa99c57 | ||
|
|
0c03e257b7 | ||
|
|
85e3a00d9d | ||
|
|
edd51102ad | ||
|
|
f5fccab1f6 | ||
|
|
ef12bc5381 | ||
|
|
b197e0af47 | ||
|
|
341b695ad3 | ||
|
|
d46d99a35e | ||
|
|
653168fc3d | ||
|
|
cb5e7f0130 | ||
|
|
312b9fbfb1 | ||
|
|
ba599c7dd6 | ||
|
|
7e359eda4a |
2
.github/workflows/openhands-resolver.yml
vendored
2
.github/workflows/openhands-resolver.yml
vendored
@@ -232,6 +232,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
@@ -268,6 +269,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
|
||||
@@ -100,7 +100,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.23-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.24-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -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.23-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.23-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.24-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.24-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:
|
||||
|
||||
@@ -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.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -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.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -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.23-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
@@ -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.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -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.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -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.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](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)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -54,14 +54,13 @@ graph TD
|
||||
6. Action Execution: The runtime client receives actions from the backend, executes them in the sandboxed environment, and sends back observations
|
||||
7. Observation Return: The action execution server sends execution results back to the OpenHands backend as observations
|
||||
|
||||
|
||||
The role of the client:
|
||||
|
||||
- It acts as an intermediary between the OpenHands backend and the sandboxed environment
|
||||
- It executes various types of actions (shell commands, file operations, Python code, etc.) safely within the container
|
||||
- It manages the state of the sandboxed environment, including the current working directory and loaded plugins
|
||||
- It formats and returns observations to the backend, ensuring a consistent interface for processing results
|
||||
|
||||
|
||||
## How OpenHands builds and maintains OH Runtime images
|
||||
|
||||
OpenHands' approach to building and managing runtime images ensures efficiency, consistency, and flexibility in creating and maintaining Docker images for both production and development environments.
|
||||
@@ -78,16 +77,15 @@ Tags may be in one of 2 formats:
|
||||
- **Source Tag**: `oh_v{openhands_version}_{16_digit_lock_hash}_{16_digit_source_hash}`
|
||||
(e.g.: `oh_v0.9.9_1234567890abcdef_1234567890abcdef`)
|
||||
|
||||
|
||||
#### Source Tag - Most Specific
|
||||
|
||||
This is the first 16 digits of the MD5 of the directory hash for the source directory. This gives a hash
|
||||
for only the openhands source
|
||||
|
||||
|
||||
#### Lock Tag
|
||||
|
||||
This hash is built from the first 16 digits of the MD5 of:
|
||||
|
||||
- The name of the base image upon which the image was built (e.g.: `nikolaik/python-nodejs:python3.12-nodejs22`)
|
||||
- The content of the `pyproject.toml` included in the image.
|
||||
- The content of the `poetry.lock` included in the image.
|
||||
|
||||
21
docs/modules/usage/cloud/cloud-github-resolver.md
Normal file
21
docs/modules/usage/cloud/cloud-github-resolver.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Cloud GitHub Resolver
|
||||
|
||||
The GitHub Resolver automates code fixes and provides intelligent assistance for your repositories.
|
||||
|
||||
## Setup
|
||||
|
||||
The Cloud Github Resolver is available automatically when you
|
||||
[grant OpenHands Cloud repository access](./openhands-cloud.md#adding-repositories).
|
||||
|
||||
## Usage
|
||||
|
||||
### Issues
|
||||
|
||||
On your repository, label an issue with `openhands`. OpenHands will attempt to fix the issue.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
In order to get OpenHands to work on pull requests, use `@openhands` in top level or single inline comments to:
|
||||
- Ask questions
|
||||
- Request updates
|
||||
- Get code explanations
|
||||
39
docs/modules/usage/cloud/openhands-cloud.md
Normal file
39
docs/modules/usage/cloud/openhands-cloud.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Openhands Cloud
|
||||
|
||||
OpenHands Cloud is the cloud hosted version of OpenHands by All Hands AI.
|
||||
|
||||
## Accessing OpenHands Cloud
|
||||
|
||||
Currently, users are being admitted to access OpenHands Cloud in waves. To sign up,
|
||||
[join the waitlist](https://www.all-hands.dev/join-waitlist). Once you are approved, you will get an email with
|
||||
instructions on how to access it.
|
||||
|
||||
## Getting Started
|
||||
|
||||
After visiting OpenHands Cloud, you will be asked to connect with your GitHub account:
|
||||
1. After reading and accepting the terms of service, click `Connect to GitHub`.
|
||||
2. Review the permissions requested by OpenHands and then click `Authorize OpenHands by All Hands AI`.
|
||||
- OpenHands will require some permissions from your GitHub account. To read more about these permissions,
|
||||
you can click the `Learn more` link on the GitHub authorize page.
|
||||
|
||||
## Adding Repositories
|
||||
|
||||
You can grant OpenHands specific repository access:
|
||||
1. Under the `Select a GitHub project` dropdown, select `Add more repositories...`.
|
||||
2. Select the organization, then choose the specific repositories to grant OpenHands access to.
|
||||
- Openhands requests short-lived tokens (8-hour expiry) with these permissions:
|
||||
- Actions: Read and write
|
||||
- Administration: Read-only
|
||||
- Commit statuses: Read and write
|
||||
- Contents: Read and write
|
||||
- Issues: Read and write
|
||||
- Metadata: Read-only
|
||||
- Pull requests: Read and write
|
||||
- Webhooks: Read and write
|
||||
- Workflows: Read and write
|
||||
- Repository access for a user is granted based on:
|
||||
- Granted permission for the repository.
|
||||
- User's GitHub permissions (owner/collaborator).
|
||||
|
||||
You can manage repository access any time by following the above workflow or visiting the Settings page and selecting
|
||||
`Configure GitHub Repositories` under the `GitHub Settings` section.
|
||||
@@ -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.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -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.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
- Linux
|
||||
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
|
||||
|
||||
A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<details>
|
||||
@@ -54,17 +56,17 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.24
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
@@ -63,22 +63,22 @@ We have a few guides for running OpenHands with specific model providers:
|
||||
### API retries and rate limits
|
||||
|
||||
LLM providers typically have rate limits, sometimes very low, and may require retries. OpenHands will automatically
|
||||
retry requests if it receives a Rate Limit Error (429 error code), API connection error, or other transient errors.
|
||||
retry requests if it receives a Rate Limit Error (429 error code).
|
||||
|
||||
You can customize these options as you need for the provider you're using. Check their documentation, and set the
|
||||
following environment variables to control the number of retries and the time between retries:
|
||||
|
||||
- `LLM_NUM_RETRIES` (Default of 8)
|
||||
- `LLM_RETRY_MIN_WAIT` (Default of 15 seconds)
|
||||
- `LLM_RETRY_MAX_WAIT` (Default of 120 seconds)
|
||||
- `LLM_NUM_RETRIES` (Default of 4 times)
|
||||
- `LLM_RETRY_MIN_WAIT` (Default of 5 seconds)
|
||||
- `LLM_RETRY_MAX_WAIT` (Default of 30 seconds)
|
||||
- `LLM_RETRY_MULTIPLIER` (Default of 2)
|
||||
|
||||
If you are running OpenHands in development mode, you can also set these options in the `config.toml` file:
|
||||
|
||||
```toml
|
||||
[llm]
|
||||
num_retries = 8
|
||||
retry_min_wait = 15
|
||||
retry_max_wait = 120
|
||||
num_retries = 4
|
||||
retry_min_wait = 5
|
||||
retry_max_wait = 30
|
||||
retry_multiplier = 2
|
||||
```
|
||||
|
||||
@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -42,7 +42,7 @@ const sidebars: SidebarsConfig = {
|
||||
id: 'usage/prompting/microagents-public',
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -69,6 +69,23 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Github Actions',
|
||||
id: 'usage/how-to/github-action',
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Cloud',
|
||||
items: [
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Openhands Cloud',
|
||||
id: 'usage/cloud/openhands-cloud',
|
||||
},
|
||||
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Cloud GitHub Resolver',
|
||||
id: 'usage/cloud/cloud-github-resolver',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -185,7 +202,7 @@ const sidebars: SidebarsConfig = {
|
||||
type: 'doc',
|
||||
label: 'About',
|
||||
id: 'usage/about',
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -17,7 +18,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
@@ -60,17 +60,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=False,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -17,6 +17,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -25,7 +26,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -40,21 +40,15 @@ from openhands.utils.async_utils import call_async_from_sync
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-slim'
|
||||
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-slim',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=3600,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -24,7 +25,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
load_from_toml,
|
||||
parse_arguments,
|
||||
@@ -47,22 +47,14 @@ SKIP_NUM = (
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.11-bookworm'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.11-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
timeout=100,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=1800,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -14,6 +14,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -22,7 +23,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -57,18 +57,15 @@ def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
BIOCODER_BENCH_CONTAINER_IMAGE = 'public.ecr.aws/i5g0m1f6/eval_biocoder:v1.0'
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = BIOCODER_BENCH_CONTAINER_IMAGE
|
||||
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image=BIOCODER_BENCH_CONTAINER_IMAGE,
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -17,6 +17,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -25,7 +26,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -71,17 +71,15 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -18,7 +19,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -36,17 +36,14 @@ def get_config(
|
||||
assert (
|
||||
metadata.max_iterations == 1
|
||||
), 'max_iterations must be 1 for browsing delegation evaluation.'
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=False,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -25,7 +26,6 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
@@ -105,9 +105,7 @@ def get_config(
|
||||
instance: pd.Series,
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
# COMMIT0_CONTAINER_IMAGE = 'wentingzhao/'
|
||||
assert USE_INSTANCE_IMAGE
|
||||
# We use a different instance image for the each instance of commit0 eval
|
||||
repo_name = instance['repo'].split('/')[1]
|
||||
base_container_image = get_instance_docker_image(repo_name)
|
||||
logger.info(
|
||||
@@ -115,28 +113,16 @@ def get_config(
|
||||
f'Please make sure this image exists. '
|
||||
f'Submit an issue on https://github.com/All-Hands-AI/OpenHands if you run into any issues.'
|
||||
)
|
||||
# else:
|
||||
# raise
|
||||
# base_container_image = SWE_BENCH_CONTAINER_IMAGE
|
||||
# logger.info(f'Using swe-bench container image: {base_container_image}')
|
||||
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = base_container_image
|
||||
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image=base_container_image,
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
# large enough timeout, since some testcases take very long to run
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=3600,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -25,7 +26,6 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -62,17 +62,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -13,6 +13,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -21,10 +22,10 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.utils import get_agent_config_arg
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
|
||||
@@ -47,24 +48,25 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
if metadata.agent_config:
|
||||
config.set_agent_config(metadata.agent_config, metadata.agent_class)
|
||||
else:
|
||||
logger.info('Agent config not provided, using default settings')
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -238,6 +240,10 @@ if __name__ == '__main__':
|
||||
)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
agent_config = None
|
||||
if args.agent_config:
|
||||
agent_config = get_agent_config_arg(args.agent_config)
|
||||
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
@@ -256,6 +262,7 @@ if __name__ == '__main__':
|
||||
eval_output_dir=args.eval_output_dir,
|
||||
data_split=args.data_split,
|
||||
details={'gaia-level': args.level},
|
||||
agent_config=agent_config,
|
||||
)
|
||||
|
||||
dataset = load_dataset('gaia-benchmark/GAIA', args.level)
|
||||
|
||||
@@ -9,6 +9,7 @@ AGENT=$3
|
||||
EVAL_LIMIT=$4
|
||||
LEVELS=$5
|
||||
NUM_WORKERS=$6
|
||||
AGENT_CONFIG=$7
|
||||
|
||||
if [ -z "$NUM_WORKERS" ]; then
|
||||
NUM_WORKERS=1
|
||||
@@ -49,5 +50,9 @@ if [ -n "$EVAL_LIMIT" ]; then
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
if [ -n "$AGENT_CONFIG" ]; then
|
||||
echo "AGENT_CONFIG: $AGENT_CONFIG"
|
||||
COMMAND="$COMMAND --agent-config $AGENT_CONFIG"
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
|
||||
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -19,7 +20,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
@@ -40,17 +40,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -29,6 +29,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -37,7 +38,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
@@ -61,17 +61,14 @@ ACTION_FORMAT = """
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -22,6 +22,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -30,7 +31,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -82,17 +82,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -9,6 +9,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -17,7 +18,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
@@ -45,18 +45,18 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'xingyaoww/od-eval-logic-reasoning:v1.0'
|
||||
sandbox_config.runtime_extra_deps = (
|
||||
'$OH_INTERPRETER_PATH -m pip install scitools-pyke'
|
||||
)
|
||||
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='xingyaoww/od-eval-logic-reasoning:v1.0',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
runtime_extra_deps='$OH_INTERPRETER_PATH -m pip install scitools-pyke',
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -12,6 +12,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -21,7 +22,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -55,23 +55,14 @@ def get_config(
|
||||
metadata: EvalMetadata,
|
||||
env_id: str,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'xingyaoww/od-eval-miniwob:v1.0'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
browsergym_eval_env=env_id,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
remote_runtime_init_timeout=1800,
|
||||
keep_runtime_alive=False,
|
||||
timeout=120,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -14,6 +14,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -22,7 +23,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
@@ -103,18 +103,18 @@ def load_incontext_example(task_name: str, with_tool: bool = True):
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'xingyaoww/od-eval-mint:v1.0'
|
||||
sandbox_config.runtime_extra_deps = (
|
||||
f'$OH_INTERPRETER_PATH -m pip install {" ".join(MINT_DEPENDENCIES)}'
|
||||
)
|
||||
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='xingyaoww/od-eval-mint:v1.0',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
runtime_extra_deps=f'$OH_INTERPRETER_PATH -m pip install {" ".join(MINT_DEPENDENCIES)}',
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -25,6 +25,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -33,7 +34,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_app_config,
|
||||
@@ -77,16 +77,14 @@ ID2CONDA = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'public.ecr.aws/i5g0m1f6/ml-bench'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='public.ecr.aws/i5g0m1f6/ml-bench',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -20,7 +21,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
@@ -59,22 +59,17 @@ def get_config(
|
||||
metadata: EvalMetadata,
|
||||
instance_id: str,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = (
|
||||
'docker.io/xingyaoww/openhands-eval-scienceagentbench'
|
||||
)
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_budget_per_task=4,
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='docker.io/xingyaoww/openhands-eval-scienceagentbench',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from functools import partial
|
||||
@@ -21,13 +22,14 @@ from evaluation.benchmarks.swe_bench.run_infer import get_instance_docker_image
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
get_default_sandbox_config_for_eval,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
)
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
LLMConfig,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -79,22 +81,16 @@ def get_config(metadata: EvalMetadata, instance: pd.Series) -> AppConfig:
|
||||
f'Please make sure this image exists. '
|
||||
f'Submit an issue on https://github.com/All-Hands-AI/OpenHands if you run into any issues.'
|
||||
)
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = base_container_image
|
||||
sandbox_config.remote_runtime_resource_factor = get_instance_resource_factor(
|
||||
dataset_name=metadata.dataset,
|
||||
instance_id=instance['instance_id'],
|
||||
)
|
||||
config = AppConfig(
|
||||
run_as_openhands=False,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image=base_container_image,
|
||||
use_host_network=False,
|
||||
# large enough timeout, since some testcases take very long to run
|
||||
timeout=600,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
remote_runtime_init_timeout=3600,
|
||||
remote_runtime_resource_factor=get_instance_resource_factor(
|
||||
dataset_name=metadata.dataset,
|
||||
instance_id=instance['instance_id'],
|
||||
),
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
@@ -415,13 +411,17 @@ if __name__ == '__main__':
|
||||
else:
|
||||
# Initialize with a dummy metadata when file doesn't exist
|
||||
metadata = EvalMetadata(
|
||||
agent_class="dummy_agent", # Placeholder agent class
|
||||
llm_config=LLMConfig(model="dummy_model"), # Minimal LLM config
|
||||
agent_class='dummy_agent', # Placeholder agent class
|
||||
llm_config=LLMConfig(model='dummy_model'), # Minimal LLM config
|
||||
max_iterations=1, # Minimal iterations
|
||||
eval_output_dir=os.path.dirname(args.input_file), # Use input file dir as output dir
|
||||
eval_output_dir=os.path.dirname(
|
||||
args.input_file
|
||||
), # Use input file dir as output dir
|
||||
start_time=time.strftime('%Y-%m-%d %H:%M:%S'), # Current time
|
||||
git_commit=subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('utf-8').strip(), # Current commit
|
||||
dataset=args.dataset # Dataset name from args
|
||||
git_commit=subprocess.check_output(['git', 'rev-parse', 'HEAD'])
|
||||
.decode('utf-8')
|
||||
.strip(), # Current commit
|
||||
dataset=args.dataset, # Dataset name from args
|
||||
)
|
||||
|
||||
# The evaluation harness constrains the signature of `process_instance_func` but we need to
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"pydata__xarray-6721": 8, "pytest-dev__pytest-7236": 8, "matplotlib__matplotlib-24627": 4, "django__django-15561": 4, "django__django-15098": 4, "django__django-14771": 4, "sympy__sympy-21612": 4, "sympy__sympy-15345": 4, "psf__requests-5414": 4, "astropy__astropy-14508": 2, "django__django-11451": 2, "django__django-11477": 2, "django__django-10880": 2, "django__django-11163": 2, "django__django-11815": 2, "astropy__astropy-14369": 2, "django__django-10097": 2, "django__django-10554": 2, "django__django-12304": 2, "django__django-12325": 2, "django__django-11551": 2, "django__django-11734": 2, "django__django-13109": 2, "django__django-13089": 2, "django__django-13343": 2, "django__django-13363": 2, "django__django-13809": 2, "django__django-13810": 2, "django__django-13786": 2, "django__django-13807": 2, "django__django-14493": 2, "django__django-11820": 2, "django__django-11951": 2, "django__django-11964": 2, "astropy__astropy-14309": 2, "astropy__astropy-14365": 2, "astropy__astropy-12907": 2, "astropy__astropy-14182": 2, "django__django-15161": 2, "django__django-15128": 2, "django__django-14999": 2, "django__django-14915": 2, "django__django-14752": 2, "django__django-14765": 2, "django__django-14089": 2, "django__django-15252": 2, "django__django-15380": 2, "django__django-15382": 2, "django__django-15499": 2, "django__django-15467": 2, "django__django-15280": 2, "django__django-15315": 2, "django__django-15277": 2, "django__django-15268": 2, "django__django-15629": 2, "django__django-15695": 2, "django__django-15732": 2, "django__django-15863": 2, "django__django-16082": 2, "django__django-16145": 2, "django__django-16256": 2, "django__django-16429": 2, "django__django-16454": 2, "django__django-16493": 2, "matplotlib__matplotlib-13989": 2, "matplotlib__matplotlib-20488": 2, "django__django-15503": 2, "django__django-15525": 2, "django__django-15375": 2, "django__django-15278": 2, "matplotlib__matplotlib-21568": 2, "matplotlib__matplotlib-20859": 2, "matplotlib__matplotlib-20826": 2, "matplotlib__matplotlib-20676": 2, "matplotlib__matplotlib-23412": 2, "matplotlib__matplotlib-22719": 2, "matplotlib__matplotlib-23299": 2, "matplotlib__matplotlib-22865": 2, "matplotlib__matplotlib-24149": 2, "matplotlib__matplotlib-24177": 2, "matplotlib__matplotlib-24570": 2, "matplotlib__matplotlib-24637": 2, "matplotlib__matplotlib-24970": 2, "matplotlib__matplotlib-23476": 2, "matplotlib__matplotlib-24026": 2, "matplotlib__matplotlib-23314": 2, "matplotlib__matplotlib-25332": 2, "matplotlib__matplotlib-25311": 2, "matplotlib__matplotlib-25122": 2, "matplotlib__matplotlib-25479": 2, "matplotlib__matplotlib-26342": 2, "psf__requests-2317": 2, "matplotlib__matplotlib-25960": 2, "matplotlib__matplotlib-25775": 2, "pydata__xarray-4356": 2, "pydata__xarray-4075": 2, "pydata__xarray-6461": 2, "pydata__xarray-4687": 2, "pydata__xarray-6599": 2, "pylint-dev__pylint-4661": 2, "django__django-15554": 2, "django__django-15563": 2, "pytest-dev__pytest-5262": 2, "pytest-dev__pytest-10081": 2, "scikit-learn__scikit-learn-12973": 2, "scikit-learn__scikit-learn-13124": 2, "scikit-learn__scikit-learn-13779": 2, "scikit-learn__scikit-learn-14141": 2, "scikit-learn__scikit-learn-13439": 2, "scikit-learn__scikit-learn-13496": 2, "scikit-learn__scikit-learn-15100": 2, "scikit-learn__scikit-learn-25102": 2, "scikit-learn__scikit-learn-25232": 2, "scikit-learn__scikit-learn-25747": 2, "scikit-learn__scikit-learn-26323": 2, "scikit-learn__scikit-learn-9288": 2, "scikit-learn__scikit-learn-14496": 2, "scikit-learn__scikit-learn-14629": 2, "sphinx-doc__sphinx-8265": 2, "sphinx-doc__sphinx-8548": 2, "sphinx-doc__sphinx-8593": 2, "sphinx-doc__sphinx-8595": 2, "sphinx-doc__sphinx-8621": 2, "sphinx-doc__sphinx-8638": 2, "sphinx-doc__sphinx-9229": 2, "sphinx-doc__sphinx-9281": 2, "sphinx-doc__sphinx-9461": 2, "sphinx-doc__sphinx-9591": 2, "sphinx-doc__sphinx-9658": 2, "sphinx-doc__sphinx-9673": 2, "sympy__sympy-12096": 2, "sympy__sympy-12481": 2, "sphinx-doc__sphinx-10323": 2, "sphinx-doc__sphinx-7590": 2, "sympy__sympy-13877": 2, "sympy__sympy-12489": 2, "sympy__sympy-15809": 2, "sympy__sympy-14711": 2, "sympy__sympy-16597": 2, "sympy__sympy-16766": 2, "sympy__sympy-16792": 2, "sympy__sympy-15875": 2, "sympy__sympy-17655": 2, "sympy__sympy-18189": 2, "sympy__sympy-18763": 2, "sympy__sympy-19040": 2, "sympy__sympy-19495": 2, "sympy__sympy-19637": 2, "sympy__sympy-19783": 2, "sympy__sympy-17630": 2, "sympy__sympy-20428": 2, "sympy__sympy-20590": 2, "sympy__sympy-20801": 2, "sympy__sympy-21379": 2, "sympy__sympy-21847": 2, "sympy__sympy-22456": 2, "sympy__sympy-22714": 2, "sympy__sympy-22914": 2, "sympy__sympy-23262": 2, "sympy__sympy-23413": 2, "sympy__sympy-23534": 2, "sympy__sympy-24066": 2, "sympy__sympy-24213": 2, "sympy__sympy-24443": 2, "sympy__sympy-24562": 2, "sympy__sympy-24661": 2}
|
||||
@@ -18,6 +18,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
@@ -30,7 +31,6 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
@@ -122,30 +122,23 @@ def get_config(
|
||||
base_container_image = SWE_BENCH_CONTAINER_IMAGE
|
||||
logger.info(f'Using swe-bench container image: {base_container_image}')
|
||||
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = base_container_image
|
||||
sandbox_config.enable_auto_lint = True
|
||||
sandbox_config.use_host_network = False
|
||||
# Add platform to the sandbox config to solve issue 4401
|
||||
sandbox_config.platform = 'linux/amd64'
|
||||
sandbox_config.remote_runtime_resource_factor = get_instance_resource_factor(
|
||||
dataset_name=metadata.dataset,
|
||||
instance_id=instance['instance_id'],
|
||||
)
|
||||
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image=base_container_image,
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
# large enough timeout, since some testcases take very long to run
|
||||
timeout=300,
|
||||
# Add platform to the sandbox config to solve issue 4401
|
||||
platform='linux/amd64',
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=3600,
|
||||
remote_runtime_api_timeout=120,
|
||||
remote_runtime_resource_factor=get_instance_resource_factor(
|
||||
dataset_name=metadata.dataset,
|
||||
instance_id=instance['instance_id'],
|
||||
),
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
@@ -331,6 +324,22 @@ def complete_runtime(
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
if obs.exit_code == -1:
|
||||
# The previous command is still running
|
||||
# We need to kill previous command
|
||||
logger.info('The previous command is still running, trying to kill it...')
|
||||
action = CmdRunAction(command='C-c')
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
# Then run the command again
|
||||
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
|
||||
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 cd to /workspace/{workspace_dir_name}: {str(obs)}',
|
||||
|
||||
@@ -267,7 +267,9 @@ def pre_login(
|
||||
obs: BrowserOutputObservation = runtime.run_action(browser_action)
|
||||
logger.debug(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
if save_screenshots:
|
||||
image_data = base64.b64decode(obs.screenshot)
|
||||
image_data = base64.b64decode(
|
||||
obs.screenshot.replace('data:image/png;base64,', '')
|
||||
)
|
||||
with open(os.path.join(directory, f'{image_id}.png'), 'wb') as file:
|
||||
file.write(image_data)
|
||||
image_id += 1
|
||||
|
||||
@@ -13,14 +13,16 @@ from typing import List
|
||||
import yaml
|
||||
from browsing import pre_login
|
||||
|
||||
from evaluation.utils.shared import get_default_sandbox_config_for_eval
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
LLMConfig,
|
||||
SandboxConfig,
|
||||
get_agent_config_arg,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import CmdRunAction, MessageAction
|
||||
@@ -34,7 +36,10 @@ def get_config(
|
||||
task_short_name: str,
|
||||
mount_path_on_host: str,
|
||||
llm_config: LLMConfig,
|
||||
agent_config: AgentConfig | None,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = base_container_image
|
||||
config = AppConfig(
|
||||
run_as_openhands=False,
|
||||
max_budget_per_task=4,
|
||||
@@ -42,22 +47,21 @@ def get_config(
|
||||
save_trajectory_path=os.path.join(
|
||||
mount_path_on_host, f'traj_{task_short_name}.json'
|
||||
),
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image=base_container_image,
|
||||
enable_auto_lint=True,
|
||||
# using host network to access the host machine from the container
|
||||
use_host_network=True,
|
||||
# large enough timeout, since some testcases take very long to run
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# we mount trajectories path so that trajectories, generated by OpenHands
|
||||
# controller, can be accessible to the evaluator file in the runtime container
|
||||
workspace_mount_path=mount_path_on_host,
|
||||
workspace_mount_path_in_sandbox='/outputs',
|
||||
)
|
||||
config.set_llm_config(llm_config)
|
||||
if agent_config:
|
||||
config.set_agent_config(agent_config)
|
||||
else:
|
||||
logger.info('Agent config not provided, using default settings')
|
||||
agent_config = AgentConfig(
|
||||
enable_prompt_extensions=False,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -148,11 +152,21 @@ def run_solver(
|
||||
os.makedirs(screenshots_dir, exist_ok=True)
|
||||
for image_id, obs in enumerate(state.history):
|
||||
if isinstance(obs, BrowserOutputObservation):
|
||||
image_data = base64.b64decode(obs.screenshot)
|
||||
image_data = base64.b64decode(
|
||||
obs.screenshot.replace('data:image/png;base64,', '')
|
||||
)
|
||||
with open(
|
||||
os.path.join(screenshots_dir, f'{image_id}.png'), 'wb'
|
||||
) as file:
|
||||
file.write(image_data)
|
||||
if obs.set_of_marks:
|
||||
som_image_data = base64.b64decode(
|
||||
obs.set_of_marks.replace('data:image/png;base64,', '')
|
||||
)
|
||||
with open(
|
||||
os.path.join(screenshots_dir, f'{image_id}_som.png'), 'wb'
|
||||
) as file:
|
||||
file.write(som_image_data)
|
||||
|
||||
if save_final_state:
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
@@ -215,6 +229,10 @@ if __name__ == '__main__':
|
||||
)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
agent_config: AgentConfig | None = None
|
||||
if args.agent_config:
|
||||
agent_config = get_agent_config_arg(args.agent_config)
|
||||
|
||||
agent_llm_config: LLMConfig | None = None
|
||||
if args.agent_llm_config:
|
||||
agent_llm_config = get_llm_config_arg(args.agent_llm_config)
|
||||
@@ -255,7 +273,7 @@ if __name__ == '__main__':
|
||||
else:
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
config: AppConfig = get_config(
|
||||
args.task_image_name, task_short_name, temp_dir, agent_llm_config
|
||||
args.task_image_name, task_short_name, temp_dir, agent_llm_config, agent_config
|
||||
)
|
||||
runtime: Runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
@@ -44,6 +44,10 @@ while [[ $# -gt 0 ]]; do
|
||||
ENV_LLM_CONFIG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--agent-config)
|
||||
AGENT_CONFIG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--outputs-path)
|
||||
OUTPUTS_PATH="$2"
|
||||
shift 2
|
||||
@@ -125,8 +129,6 @@ temp_file="tasks_${START_PERCENTILE}_${END_PERCENTILE}.md"
|
||||
sed -n "${start_line},${end_line}p" tasks.md > "$temp_file"
|
||||
|
||||
while IFS= read -r task_image; do
|
||||
docker pull $task_image
|
||||
|
||||
# Remove prefix using ## to remove longest matching pattern from start
|
||||
task_name=${task_image##ghcr.io/theagentcompany/}
|
||||
|
||||
@@ -140,13 +142,23 @@ while IFS= read -r task_image; do
|
||||
continue
|
||||
fi
|
||||
|
||||
export PYTHONPATH=evaluation/benchmarks/the_agent_company:\$PYTHONPATH && \
|
||||
poetry run python run_infer.py \
|
||||
--agent-llm-config "$AGENT_LLM_CONFIG" \
|
||||
--env-llm-config "$ENV_LLM_CONFIG" \
|
||||
--outputs-path "$OUTPUTS_PATH" \
|
||||
--server-hostname "$SERVER_HOSTNAME" \
|
||||
--task-image-name "$task_image"
|
||||
docker pull $task_image
|
||||
|
||||
# Build the Python command
|
||||
COMMAND="poetry run python run_infer.py \
|
||||
--agent-llm-config \"$AGENT_LLM_CONFIG\" \
|
||||
--env-llm-config \"$ENV_LLM_CONFIG\" \
|
||||
--outputs-path \"$OUTPUTS_PATH\" \
|
||||
--server-hostname \"$SERVER_HOSTNAME\" \
|
||||
--task-image-name \"$task_image\""
|
||||
|
||||
# Add agent-config if it's defined
|
||||
if [ -n "$AGENT_CONFIG" ]; then
|
||||
COMMAND="$COMMAND --agent-config $AGENT_CONFIG"
|
||||
fi
|
||||
|
||||
export PYTHONPATH=evaluation/benchmarks/the_agent_company:$PYTHONPATH && \
|
||||
eval "$COMMAND"
|
||||
|
||||
# Prune unused images and volumes
|
||||
docker image rm "$task_image"
|
||||
|
||||
@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -18,7 +19,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
@@ -41,17 +41,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -20,7 +21,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -55,32 +55,29 @@ def get_config(
|
||||
assert base_url is not None, 'VISUALWEBARENA_BASE_URL must be set'
|
||||
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
|
||||
assert openai_base_url is not None, 'OPENAI_BASE_URL must be set'
|
||||
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
sandbox_config.browsergym_eval_env = env_id
|
||||
sandbox_config.runtime_startup_env_vars = {
|
||||
'BASE_URL': base_url,
|
||||
'OPENAI_API_KEY': openai_api_key,
|
||||
'OPENAI_BASE_URL': openai_base_url,
|
||||
'VWA_CLASSIFIEDS': f'{base_url}:9980',
|
||||
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
|
||||
'VWA_SHOPPING': f'{base_url}:7770',
|
||||
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
|
||||
'VWA_REDDIT': f'{base_url}:9999',
|
||||
'VWA_GITLAB': f'{base_url}:8023',
|
||||
'VWA_WIKIPEDIA': f'{base_url}:8888',
|
||||
'VWA_HOMEPAGE': f'{base_url}:4399',
|
||||
}
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
browsergym_eval_env=env_id,
|
||||
runtime_startup_env_vars={
|
||||
'BASE_URL': base_url,
|
||||
'OPENAI_API_KEY': openai_api_key,
|
||||
'OPENAI_BASE_URL': openai_base_url,
|
||||
'VWA_CLASSIFIEDS': f'{base_url}:9980',
|
||||
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
|
||||
'VWA_SHOPPING': f'{base_url}:7770',
|
||||
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
|
||||
'VWA_REDDIT': f'{base_url}:9999',
|
||||
'VWA_GITLAB': f'{base_url}:8023',
|
||||
'VWA_WIKIPEDIA': f'{base_url}:8888',
|
||||
'VWA_HOMEPAGE': f'{base_url}:4399',
|
||||
},
|
||||
timeout=300,
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -19,7 +20,6 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -50,29 +50,26 @@ def get_config(
|
||||
assert base_url is not None, 'WEBARENA_BASE_URL must be set'
|
||||
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
|
||||
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
sandbox_config.browsergym_eval_env = env_id
|
||||
sandbox_config.runtime_startup_env_vars = {
|
||||
'BASE_URL': base_url,
|
||||
'OPENAI_API_KEY': openai_api_key,
|
||||
'SHOPPING': f'{base_url}:7770/',
|
||||
'SHOPPING_ADMIN': f'{base_url}:7780/admin',
|
||||
'REDDIT': f'{base_url}:9999',
|
||||
'GITLAB': f'{base_url}:8023',
|
||||
'WIKIPEDIA': f'{base_url}:8888/wikipedia_en_all_maxi_2022-05/A/User:The_other_Kiwix_guy/Landing',
|
||||
'MAP': f'{base_url}:3000',
|
||||
'HOMEPAGE': f'{base_url}:4399',
|
||||
}
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
browsergym_eval_env=env_id,
|
||||
runtime_startup_env_vars={
|
||||
'BASE_URL': base_url,
|
||||
'OPENAI_API_KEY': openai_api_key,
|
||||
'SHOPPING': f'{base_url}:7770/',
|
||||
'SHOPPING_ADMIN': f'{base_url}:7780/admin',
|
||||
'REDDIT': f'{base_url}:9999',
|
||||
'GITLAB': f'{base_url}:8023',
|
||||
'WIKIPEDIA': f'{base_url}:8888/wikipedia_en_all_maxi_2022-05/A/User:The_other_Kiwix_guy/Landing',
|
||||
'MAP': f'{base_url}:3000',
|
||||
'HOMEPAGE': f'{base_url}:4399',
|
||||
},
|
||||
remote_runtime_enable_retries=True,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -8,6 +8,7 @@ from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestRes
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
get_default_sandbox_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -21,7 +22,6 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
@@ -43,23 +43,14 @@ def get_config(
|
||||
metadata: EvalMetadata,
|
||||
instance_id: str,
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.platform = 'linux/amd64'
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
# use default base_container_image
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
timeout=300,
|
||||
# Add platform to the sandbox config to solve issue 4401
|
||||
platform='linux/amd64',
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=3600,
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
|
||||
@@ -16,7 +16,8 @@ from pydantic import BaseModel
|
||||
from tqdm import tqdm
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.config import LLMConfig, SandboxConfig
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.condenser_config import (
|
||||
CondenserConfig,
|
||||
NoOpCondenserConfig,
|
||||
@@ -43,6 +44,7 @@ from openhands.memory.condenser import get_condensation_metadata
|
||||
class EvalMetadata(BaseModel):
|
||||
agent_class: str
|
||||
llm_config: LLMConfig
|
||||
agent_config: AgentConfig | None = None
|
||||
max_iterations: int
|
||||
eval_output_dir: str
|
||||
start_time: str
|
||||
@@ -167,6 +169,7 @@ def make_metadata(
|
||||
eval_output_dir: str,
|
||||
data_split: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
agent_config: AgentConfig | None = None,
|
||||
condenser_config: CondenserConfig | None = None,
|
||||
) -> EvalMetadata:
|
||||
model_name = llm_config.model.split('/')[-1]
|
||||
@@ -189,6 +192,7 @@ def make_metadata(
|
||||
metadata = EvalMetadata(
|
||||
agent_class=agent_class,
|
||||
llm_config=llm_config,
|
||||
agent_config=agent_config,
|
||||
max_iterations=max_iterations,
|
||||
eval_output_dir=eval_output_path,
|
||||
start_time=time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
@@ -551,3 +555,18 @@ def get_metrics(state: State) -> dict[str, Any]:
|
||||
metrics = state.metrics.get() if state.metrics else {}
|
||||
metrics['condenser'] = get_condensation_metadata(state)
|
||||
return metrics
|
||||
|
||||
|
||||
def get_default_sandbox_config_for_eval() -> SandboxConfig:
|
||||
return SandboxConfig(
|
||||
use_host_network=False,
|
||||
# large enough timeout, since some testcases take very long to run
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=3600,
|
||||
remote_runtime_api_timeout=120,
|
||||
remote_runtime_enable_retries=True,
|
||||
remote_runtime_class='sysbox',
|
||||
)
|
||||
|
||||
@@ -18,7 +18,6 @@ describe("AccountSettingsContextMenu", () => {
|
||||
it("should always render the right options", () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
@@ -28,30 +27,12 @@ describe("AccountSettingsContextMenu", () => {
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ACCOUNT_SETTINGS$SETTINGS")).toBeInTheDocument();
|
||||
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClickAccountSettings when the account settings option is clicked", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
|
||||
await user.click(accountSettingsOption);
|
||||
|
||||
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onLogout when the logout option is clicked", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
@@ -67,7 +48,6 @@ describe("AccountSettingsContextMenu", () => {
|
||||
test("onLogout should be disabled if the user is not logged in", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn={false}
|
||||
@@ -83,14 +63,13 @@ describe("AccountSettingsContextMenu", () => {
|
||||
it("should call onClose when clicking outside of the element", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
|
||||
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(accountSettingsButton);
|
||||
await user.click(document.body);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
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";
|
||||
@@ -8,7 +8,7 @@ import { SettingsProvider } from "#/context/settings-context";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
|
||||
describe("AnalyticsConsentFormModal", () => {
|
||||
it("should call saveUserSettings with default settings on confirm reset settings", async () => {
|
||||
it("should call saveUserSettings with consent", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
@@ -26,20 +26,9 @@ describe("AnalyticsConsentFormModal", () => {
|
||||
const confirmButton = screen.getByTestId("confirm-preferences");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(saveUserSettingsSpy).toHaveBeenCalledWith({
|
||||
user_consents_to_analytics: true,
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
github_token: undefined,
|
||||
language: "en",
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
unset_github_token: undefined,
|
||||
});
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
expect(saveUserSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_consents_to_analytics: true }),
|
||||
);
|
||||
await waitFor(() => expect(onCloseMock).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
|
||||
describe("PaymentForm", () => {
|
||||
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
|
||||
const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
const renderPaymentForm = () =>
|
||||
render(<PaymentForm />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// useBalance hook will return the balance only if the APP_MODE is "saas"
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the users current balance", async () => {
|
||||
getBalanceSpy.mockResolvedValue("100.50");
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const balance = screen.getByTestId("user-balance");
|
||||
expect(balance).toHaveTextContent("$100.50");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the users current balance to two decimal places", async () => {
|
||||
getBalanceSpy.mockResolvedValue("100");
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const balance = screen.getByTestId("user-balance");
|
||||
expect(balance).toHaveTextContent("$100.00");
|
||||
});
|
||||
});
|
||||
|
||||
test("the user can top-up a specific amount", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "50.12");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.12);
|
||||
});
|
||||
|
||||
it("should round the top-up amount to two decimal places", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "50.125456");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13);
|
||||
});
|
||||
|
||||
it("should render the payment method link", async () => {
|
||||
renderPaymentForm();
|
||||
|
||||
screen.getByTestId("payment-methods-link");
|
||||
});
|
||||
|
||||
it("should disable the top-up button if the user enters an invalid amount", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
expect(topUpButton).toBeDisabled();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, " ");
|
||||
|
||||
expect(topUpButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable the top-up button after submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "50.12");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(topUpButton).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("prevent submission if", () => {
|
||||
test("user enters a negative amount", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "-50.12");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters an empty string", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, " ");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters a non-numeric value", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "abc");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters less than the minimum amount", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "20"); // test assumes the minimum is 25
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { AxiosError } from "axios";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
@@ -21,161 +18,14 @@ const renderSidebar = () =>
|
||||
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
|
||||
describe("Sidebar", () => {
|
||||
describe("Settings", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch settings data on mount", () => {
|
||||
renderSidebar();
|
||||
expect(getSettingsSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should send all settings data when saving AI configuration", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
"save-settings-button",
|
||||
);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not reset AI configuration when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const menu = screen.getByTestId("account-settings-context-menu");
|
||||
const accountSettingsButton = within(menu).getByTestId(
|
||||
"account-settings-button",
|
||||
);
|
||||
await user.click(accountSettingsButton);
|
||||
|
||||
const accountSettingsModal = screen.getByTestId("account-settings-form");
|
||||
|
||||
const languageInput =
|
||||
within(accountSettingsModal).getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput =
|
||||
within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_LABEL/i);
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const analyticsConsentInput =
|
||||
within(accountSettingsModal).getByTestId("analytics-consent");
|
||||
await user.click(analyticsConsentInput);
|
||||
|
||||
const saveButton =
|
||||
within(accountSettingsModal).getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
github_token: "new-token",
|
||||
language: "no",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not send the api key if its SET", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
|
||||
// Click the advanced options switch to show the API key input
|
||||
const advancedOptionsSwitch = within(settingsModal).getByTestId(
|
||||
"advanced-option-switch",
|
||||
);
|
||||
await user.click(advancedOptionsSwitch);
|
||||
|
||||
const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);
|
||||
await user.type(apiKeyInput, "**********");
|
||||
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
"save-settings-button",
|
||||
);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Settings Modal", () => {
|
||||
it("should open the settings modal if the user clicks the settings button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open the settings modal if GET /settings fails with a 404", async () => {
|
||||
const error = new AxiosError(
|
||||
"Request failed with status code 404",
|
||||
"ERR_BAD_REQUEST",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
data: { message: "Settings not found" },
|
||||
headers: {},
|
||||
// @ts-expect-error - we only need the response object for this test
|
||||
config: {},
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(OpenHands, "getSettings").mockRejectedValue(error);
|
||||
|
||||
renderSidebar();
|
||||
|
||||
const settingsModal = await screen.findByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
it("should fetch settings data on mount", () => {
|
||||
renderSidebar();
|
||||
expect(getSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import * as ConsentHandlers from "#/utils/handle-capture-consent";
|
||||
|
||||
describe("AccountSettingsModal", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.skip("should set the appropriate user analytics consent default", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const analyticsConsentInput = screen.getByTestId("analytics-consent");
|
||||
await waitFor(() => expect(analyticsConsentInput).toBeChecked());
|
||||
});
|
||||
|
||||
it("should save the users consent to analytics when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const analyticsConsentInput = screen.getByTestId("analytics-consent");
|
||||
await user.click(analyticsConsentInput);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsent with the analytics consent value if the save is successful", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
ConsentHandlers,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const analyticsConsentInput = screen.getByTestId("analytics-consent");
|
||||
await user.click(analyticsConsentInput);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
|
||||
await user.click(analyticsConsentInput);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should send all settings data when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const languageInput = screen.getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput = screen.getByTestId("github-token-input");
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "no",
|
||||
github_token: "new-token",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
user_consents_to_analytics: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a checkmark and not the input if the github token is set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const checkmark = screen.queryByTestId("github-token-set-checkmark");
|
||||
const input = screen.queryByTestId("github-token-input");
|
||||
|
||||
expect(checkmark).toBeInTheDocument();
|
||||
expect(input).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an unset github token property when pressing disconnect", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const disconnectButton = await screen.findByTestId("disconnect-github");
|
||||
await user.click(disconnectButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
github_token: undefined,
|
||||
language: "en",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
unset_github_token: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not unset the github token when changing the language", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const languageInput = screen.getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "no",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
user_consents_to_analytics: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
|
||||
describe("BrandButton", () => {
|
||||
const onClickMock = vi.fn();
|
||||
|
||||
it("should set a test id", () => {
|
||||
render(
|
||||
<BrandButton testId="brand-button" type="button" variant="primary">
|
||||
Test Button
|
||||
</BrandButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("brand-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<BrandButton type="button" variant="primary" onClick={onClickMock}>
|
||||
Test Button
|
||||
</BrandButton>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Test Button"));
|
||||
});
|
||||
|
||||
it("should be disabled if isDisabled is true", () => {
|
||||
render(
|
||||
<BrandButton type="button" variant="primary" isDisabled>
|
||||
Test Button
|
||||
</BrandButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Button")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
|
||||
109
frontend/__tests__/components/settings/settings-input.test.tsx
Normal file
109
frontend/__tests__/components/settings/settings-input.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
|
||||
describe("SettingsInput", () => {
|
||||
it("should render an optional tag if showOptionalTag is true", async () => {
|
||||
const { rerender } = render(
|
||||
<SettingsInput testId="test-input" label="Test Input" type="text" />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/optional/i)).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
showOptionalTag
|
||||
label="Test Input"
|
||||
type="text"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/optional/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable the input if isDisabled is true", async () => {
|
||||
const { rerender } = render(
|
||||
<SettingsInput testId="test-input" label="Test Input" type="text" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toBeEnabled();
|
||||
|
||||
rerender(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
isDisabled
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should set a placeholder on the input", async () => {
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
placeholder="Test Placeholder"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Test Placeholder",
|
||||
);
|
||||
});
|
||||
|
||||
it("should set a default value on the input", async () => {
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
defaultValue="Test Value"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toHaveValue("Test Value");
|
||||
});
|
||||
|
||||
it("should render start content", async () => {
|
||||
const startContent = <div>Start Content</div>;
|
||||
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
defaultValue="Test Value"
|
||||
startContent={startContent}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Start Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange with the input value", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
onChange={onChangeMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("test-input");
|
||||
await user.type(input, "Test");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledTimes(4);
|
||||
expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
|
||||
describe("SettingsSwitch", () => {
|
||||
it("should call the onChange handler when the input is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleMock = vi.fn();
|
||||
render(
|
||||
<SettingsSwitch testId="test-switch" onToggle={onToggleMock}>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
const switchInput = screen.getByTestId("test-switch");
|
||||
|
||||
await user.click(switchInput);
|
||||
expect(onToggleMock).toHaveBeenCalledWith(true);
|
||||
|
||||
await user.click(switchInput);
|
||||
expect(onToggleMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should render a beta tag if isBeta is true", () => {
|
||||
const { rerender } = render(
|
||||
<SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta={false}>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/beta/i)).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/beta/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be able to set a default toggle state", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleMock = vi.fn();
|
||||
render(
|
||||
<SettingsSwitch
|
||||
testId="test-switch"
|
||||
onToggle={onToggleMock}
|
||||
defaultIsToggled
|
||||
>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-switch")).toBeChecked();
|
||||
|
||||
const switchInput = screen.getByTestId("test-switch");
|
||||
await user.click(switchInput);
|
||||
expect(onToggleMock).toHaveBeenCalledWith(false);
|
||||
|
||||
expect(screen.getByTestId("test-switch")).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,22 @@
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
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";
|
||||
|
||||
describe("SettingsForm", () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const onCloseMock = vi.fn();
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "123",
|
||||
});
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
const RouteStub = createRoutesStub([
|
||||
{
|
||||
Component: () => (
|
||||
<SettingsForm
|
||||
settings={DEFAULT_SETTINGS}
|
||||
models={["anthropic/claude-3-5-sonnet-20241022", "model2"]}
|
||||
agents={["CodeActAgent", "agent2"]}
|
||||
securityAnalyzers={["analyzer1", "analyzer2"]}
|
||||
models={[DEFAULT_SETTINGS.LLM_MODEL]}
|
||||
onClose={onCloseMock}
|
||||
/>
|
||||
),
|
||||
@@ -38,39 +24,17 @@ describe("SettingsForm", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
it("should not show runtime size selector by default", () => {
|
||||
renderWithProviders(<RouterStub />);
|
||||
expect(screen.queryByText("Runtime Size")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show runtime size selector when advanced options are enabled", async () => {
|
||||
it("should save the user settings and close the modal when the form is submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub />);
|
||||
renderWithProviders(<RouteStub />);
|
||||
|
||||
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
|
||||
await user.click(toggleAdvancedMode);
|
||||
|
||||
await screen.findByTestId("runtime-size");
|
||||
});
|
||||
|
||||
it("should not submit the form if required fields are empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub />);
|
||||
|
||||
expect(screen.queryByTestId("custom-model-input")).not.toBeInTheDocument();
|
||||
|
||||
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
|
||||
await user.click(toggleAdvancedMode);
|
||||
|
||||
const customModelInput = screen.getByTestId("custom-model-input");
|
||||
expect(customModelInput).toBeInTheDocument();
|
||||
|
||||
await user.clear(customModelInput);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings-button");
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,24 +14,14 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle the user menu when the user avatar is clicked", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
@@ -47,30 +37,9 @@ describe("UserActions", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClickAccountSettings and close the menu when the account settings option is clicked", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
|
||||
await user.click(accountSettingsOption);
|
||||
|
||||
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onLogout and close the menu when the logout option is clicked", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
@@ -89,12 +58,7 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
test("onLogout should not be called when the user is not logged in", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
@@ -104,21 +68,4 @@ describe("UserActions", () => {
|
||||
|
||||
expect(onLogoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// FIXME: Spinner now provided through useQuery
|
||||
it.skip("should display the loading spinner", () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
user.click(userAvatar);
|
||||
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
36
frontend/__tests__/hooks/mutation/use-save-settings.test.tsx
Normal file
36
frontend/__tests__/hooks/mutation/use-save-settings.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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";
|
||||
|
||||
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(OpenHands, "saveSettings");
|
||||
const { result } = renderHook(() => useSaveSettings(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
result.current.mutate({ LLM_API_KEY: "" });
|
||||
await waitFor(() => {
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_api_key: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
result.current.mutate({ LLM_API_KEY: null });
|
||||
await waitFor(() => {
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_api_key: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,21 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import i18n from '../../src/i18n';
|
||||
import { AccountSettingsContextMenu } from '../../src/components/features/context-menu/account-settings-context-menu';
|
||||
import { renderWithProviders } from '../../test-utils';
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import i18n from "../../src/i18n";
|
||||
import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
describe('Translations', () => {
|
||||
it('should render translated text', () => {
|
||||
i18n.changeLanguage('en');
|
||||
describe("Translations", () => {
|
||||
it("should render translated text", () => {
|
||||
i18n.changeLanguage("en");
|
||||
renderWithProviders(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={() => {}}
|
||||
onLogout={() => {}}
|
||||
onClose={() => {}}
|
||||
isLoggedIn={true}
|
||||
/>
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('account-settings-context-menu')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import App from "#/routes/_oh.app/route";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
describe("App", () => {
|
||||
const RouteStub = createRoutesStub([
|
||||
@@ -35,7 +34,7 @@ describe("App", () => {
|
||||
await screen.findByTestId("app-route");
|
||||
});
|
||||
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
it(
|
||||
"should call endSession if the user does not have permission to view conversation",
|
||||
async () => {
|
||||
const errorToastSpy = vi.spyOn(toast, "error");
|
||||
|
||||
117
frontend/__tests__/routes/home.test.tsx
Normal file
117
frontend/__tests__/routes/home.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { AxiosError } from "axios";
|
||||
import MainApp from "#/routes/_oh/route";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import Home from "#/routes/_oh._index/route";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
const createAxiosNotFoundErrorObject = () =>
|
||||
new AxiosError(
|
||||
"Request failed with status code 404",
|
||||
"ERR_BAD_REQUEST",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
data: { message: "Settings not found" },
|
||||
headers: {},
|
||||
// @ts-expect-error - we only need the response object for this test
|
||||
config: {},
|
||||
},
|
||||
);
|
||||
|
||||
describe("Home Screen", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
// layout route
|
||||
Component: MainApp,
|
||||
path: "/",
|
||||
children: [
|
||||
{
|
||||
// home route
|
||||
Component: Home,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the home screen", () => {
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
});
|
||||
|
||||
it("should navigate to the settings screen when the settings button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
const settingsButton = await screen.findByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsScreen = await screen.findByTestId("settings-screen");
|
||||
expect(settingsScreen).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to the settings when pressing 'Connect to GitHub' if the user isn't authenticated", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
const connectToGitHubButton =
|
||||
await screen.findByTestId("connect-to-github");
|
||||
await user.click(connectToGitHubButton);
|
||||
|
||||
const settingsScreen = await screen.findByTestId("settings-screen");
|
||||
expect(settingsScreen).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("Settings 404", () => {
|
||||
it("should open the settings modal if GET /settings fails with a 404", async () => {
|
||||
const error = createAxiosNotFoundErrorObject();
|
||||
getSettingsSpy.mockRejectedValue(error);
|
||||
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
const settingsModal = await screen.findByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
|
||||
const error = createAxiosNotFoundErrorObject();
|
||||
getSettingsSpy.mockRejectedValue(error);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
const settingsScreen = screen.queryByTestId("settings-screen");
|
||||
expect(settingsScreen).not.toBeInTheDocument();
|
||||
|
||||
const settingsModal = await screen.findByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
|
||||
const advancedSettingsButton = await screen.findByTestId(
|
||||
"advanced-settings-link",
|
||||
);
|
||||
await user.click(advancedSettingsButton);
|
||||
|
||||
const settingsScreenAfter = await screen.findByTestId("settings-screen");
|
||||
expect(settingsScreenAfter).toBeInTheDocument();
|
||||
|
||||
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
|
||||
expect(settingsModalAfter).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
83
frontend/__tests__/routes/settings-with-payment.test.tsx
Normal file
83
frontend/__tests__/routes/settings-with-payment.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import * as FeatureFlags from "#/utils/feature-flags";
|
||||
|
||||
describe("Settings Billing", () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true);
|
||||
|
||||
const RoutesStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
Component: () => <PaymentForm />,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = () =>
|
||||
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should not render the navbar if OSS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const navbar = screen.queryByTestId("settings-navbar");
|
||||
expect(navbar).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the navbar if SaaS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const navbar = screen.getByTestId("settings-navbar");
|
||||
within(navbar).getByText("Account");
|
||||
within(navbar).getByText("Credits");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the billing settings if clicking the credits item", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
const credits = within(navbar).getByText("Credits");
|
||||
await user.click(credits);
|
||||
|
||||
const billingSection = await screen.findByTestId("billing-settings");
|
||||
within(billingSection).getByText("Manage Credits");
|
||||
});
|
||||
});
|
||||
956
frontend/__tests__/routes/settings.test.tsx
Normal file
956
frontend/__tests__/routes/settings.test.tsx
Normal file
@@ -0,0 +1,956 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { afterEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent, { UserEvent } from "@testing-library/user-event";
|
||||
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 { PostApiSettings } from "#/types/settings";
|
||||
import * as ConsentHandlers from "#/utils/handle-capture-consent";
|
||||
import AccountSettings from "#/routes/account-settings";
|
||||
|
||||
const toggleAdvancedSettings = async (user: UserEvent) => {
|
||||
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
|
||||
await user.click(advancedSwitch);
|
||||
};
|
||||
|
||||
describe("Settings Screen", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
const { handleLogoutMock } = vi.hoisted(() => ({
|
||||
handleLogoutMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("#/hooks/use-app-logout", () => ({
|
||||
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
children: [{ Component: AccountSettings, path: "/settings" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = () => {
|
||||
const queryClient = new QueryClient();
|
||||
return render(<RouterStub initialEntries={["/settings"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
it("should render", async () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByText("LLM Settings");
|
||||
screen.getByText("GitHub Settings");
|
||||
screen.getByText("Additional Settings");
|
||||
screen.getByText("Reset to defaults");
|
||||
screen.getByText("Save Changes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Account Settings", () => {
|
||||
it("should render the account settings", async () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByTestId("github-token-input");
|
||||
screen.getByTestId("github-token-help-anchor");
|
||||
screen.getByTestId("language-input");
|
||||
screen.getByTestId("enable-analytics-switch");
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Set a better unset indicator
|
||||
it.skip("should render an indicator if the GitHub token is not set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: false,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByTestId("github-token-input");
|
||||
const inputParent = input.parentElement;
|
||||
|
||||
if (inputParent) {
|
||||
const badge = within(inputParent).getByTestId("unset-indicator");
|
||||
expect(badge).toBeInTheDocument();
|
||||
} else {
|
||||
throw new Error("GitHub token input parent not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should set asterik placeholder if the GitHub token is set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByTestId("github-token-input");
|
||||
expect(input).toHaveProperty("placeholder", "**********");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render an indicator if the GitHub token is set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const input = await screen.findByTestId("github-token-input");
|
||||
const inputParent = input.parentElement;
|
||||
|
||||
if (inputParent) {
|
||||
const badge = await within(inputParent).findByTestId("set-indicator");
|
||||
expect(badge).toBeInTheDocument();
|
||||
} else {
|
||||
throw new Error("GitHub token input parent not found");
|
||||
}
|
||||
});
|
||||
|
||||
it("should render a disabled 'Disconnect from GitHub' button if the GitHub token is not set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: false,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const button = await screen.findByText("Disconnect from GitHub");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should render an enabled 'Disconnect from GitHub' button if the GitHub token is set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
const button = await screen.findByText("Disconnect from GitHub");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeEnabled();
|
||||
|
||||
// input should still be rendered
|
||||
const input = await screen.findByTestId("github-token-input");
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should logout the user when the 'Disconnect from GitHub' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const button = await screen.findByText("Disconnect from GitHub");
|
||||
await user.click(button);
|
||||
|
||||
expect(handleLogoutMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not render the 'Configure GitHub Repositories' button if OSS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const button = screen.queryByText("Configure GitHub Repositories");
|
||||
expect(button).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
APP_SLUG: "test-app",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
await screen.findByText("Configure GitHub Repositories");
|
||||
});
|
||||
|
||||
it("should not render the GitHub token input if SaaS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.queryByTestId("github-token-input");
|
||||
const helpAnchor = screen.queryByTestId("github-token-help-anchor");
|
||||
|
||||
expect(input).not.toBeInTheDocument();
|
||||
expect(helpAnchor).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("should not reset LLM Provider and Model if GitHub token is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: false,
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
});
|
||||
saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token"));
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
let llmProviderInput = await screen.findByTestId("llm-provider-input");
|
||||
let llmModelInput = await screen.findByTestId("llm-model-input");
|
||||
|
||||
expect(llmProviderInput).toHaveValue("Anthropic");
|
||||
expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022");
|
||||
|
||||
const input = await screen.findByTestId("github-token-input");
|
||||
await user.type(input, "invalid-token");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
llmProviderInput = await screen.findByTestId("llm-provider-input");
|
||||
llmModelInput = await screen.findByTestId("llm-model-input");
|
||||
|
||||
expect(llmProviderInput).toHaveValue("Anthropic");
|
||||
expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022");
|
||||
});
|
||||
|
||||
test("enabling advanced, enabling confirmation mode, and then disabling + enabling advanced should not render the security analyzer input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const confirmationModeSwitch = await screen.findByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
await user.click(confirmationModeSwitch);
|
||||
|
||||
let securityAnalyzerInput = screen.queryByTestId(
|
||||
"security-analyzer-input",
|
||||
);
|
||||
expect(securityAnalyzerInput).toBeInTheDocument();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
securityAnalyzerInput = screen.queryByTestId("security-analyzer-input");
|
||||
expect(securityAnalyzerInput).not.toBeInTheDocument();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
securityAnalyzerInput = screen.queryByTestId("security-analyzer-input");
|
||||
expect(securityAnalyzerInput).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LLM Settings", () => {
|
||||
it("should render the basic LLM settings by default", async () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByTestId("advanced-settings-switch");
|
||||
screen.getByTestId("llm-provider-input");
|
||||
screen.getByTestId("llm-model-input");
|
||||
screen.getByTestId("llm-api-key-input");
|
||||
screen.getByTestId("llm-api-key-help-anchor");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the advanced LLM settings if the advanced switch is toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
// Should not render the advanced settings by default
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const advancedSwitch = await screen.findByTestId(
|
||||
"advanced-settings-switch",
|
||||
);
|
||||
await user.click(advancedSwitch);
|
||||
|
||||
// Should render the advanced settings
|
||||
expect(
|
||||
screen.queryByTestId("llm-provider-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("llm-model-input")).not.toBeInTheDocument();
|
||||
|
||||
screen.getByTestId("llm-custom-model-input");
|
||||
screen.getByTestId("base-url-input");
|
||||
screen.getByTestId("agent-input");
|
||||
|
||||
// "Invariant" security analyzer
|
||||
screen.getByTestId("enable-confirmation-mode-switch");
|
||||
|
||||
// Not rendered until the switch is toggled
|
||||
// screen.getByTestId("security-analyzer-input");
|
||||
});
|
||||
|
||||
// TODO: Set a better unset indicator
|
||||
it.skip("should render an indicator if the LLM API key is not set", async () => {
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_api_key: null,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByTestId("llm-api-key-input");
|
||||
const inputParent = input.parentElement;
|
||||
|
||||
if (inputParent) {
|
||||
const badge = within(inputParent).getByTestId("unset-indicator");
|
||||
expect(badge).toBeInTheDocument();
|
||||
} else {
|
||||
throw new Error("LLM API Key input parent not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should render an indicator if the LLM API key is set", async () => {
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_api_key: "**********",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByTestId("llm-api-key-input");
|
||||
const inputParent = input.parentElement;
|
||||
|
||||
if (inputParent) {
|
||||
const badge = within(inputParent).getByTestId("set-indicator");
|
||||
expect(badge).toBeInTheDocument();
|
||||
} else {
|
||||
throw new Error("LLM API Key input parent not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should set asterik placeholder if the LLM API key is set", async () => {
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_api_key: "**********",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByTestId("llm-api-key-input");
|
||||
expect(input).toHaveProperty("placeholder", "**********");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Basic Model Selector", () => {
|
||||
it("should set the provider and model", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
|
||||
expect(providerInput).toHaveValue("Anthropic");
|
||||
expect(modelInput).toHaveValue("claude-3-5-sonnet-20241022");
|
||||
});
|
||||
});
|
||||
|
||||
it.todo("should change the model values if the provider is changed");
|
||||
|
||||
it.todo("should clear the model values if the provider is cleared");
|
||||
});
|
||||
|
||||
describe("Advanced LLM Settings", () => {
|
||||
it("should not render the runtime settings input if OSS mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
const input = screen.queryByTestId("runtime-settings-input");
|
||||
expect(input).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the runtime settings input if SaaS mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
screen.getByTestId("runtime-settings-input");
|
||||
});
|
||||
|
||||
it("should set the default runtime setting set", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
remote_runtime_resource_factor: 1,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(userEvent.setup());
|
||||
|
||||
const input = await screen.findByTestId("runtime-settings-input");
|
||||
expect(input).toHaveValue("1x (2 core, 8G)");
|
||||
});
|
||||
|
||||
it("should always have the runtime input disabled", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(userEvent.setup());
|
||||
|
||||
const input = await screen.findByTestId("runtime-settings-input");
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it.skip("should save the runtime settings when the 'Save Changes' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const input = await screen.findByTestId("runtime-settings-input");
|
||||
await user.click(input);
|
||||
|
||||
const option = await screen.findByText("2x (4 core, 16G)");
|
||||
await user.click(option);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remote_runtime_resource_factor: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("saving with no changes but having advanced enabled should hide the advanced items", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("base-url-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("resetting settings with no changes but having advanced enabled should hide the advanced items", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const resetButton = screen.getByText("Reset to defaults");
|
||||
await user.click(resetButton);
|
||||
|
||||
// show modal
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// confirm reset
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("base-url-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should save if only confirmation mode is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const confirmationModeSwitch = await screen.findByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
await user.click(confirmationModeSwitch);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
confirmation_mode: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle advanced if user had set a custom model", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "some/custom-model",
|
||||
});
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
expect(advancedSwitch).toBeChecked();
|
||||
|
||||
const llmCustomInput = screen.getByTestId("llm-custom-model-input");
|
||||
expect(llmCustomInput).toBeInTheDocument();
|
||||
expect(llmCustomInput).toHaveValue("some/custom-model");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have advanced settings enabled if the user previously had them enabled", async () => {
|
||||
const hasAdvancedSettingsSetSpy = vi.spyOn(
|
||||
AdvancedSettingsUtlls,
|
||||
"hasAdvancedSettingsSet",
|
||||
);
|
||||
hasAdvancedSettingsSetSpy.mockReturnValue(true);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
expect(advancedSwitch).toBeChecked();
|
||||
|
||||
const llmCustomInput = screen.getByTestId("llm-custom-model-input");
|
||||
expect(llmCustomInput).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should have confirmation mode enabled if the user previously had it enabled", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
confirmation_mode: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
expect(confirmationModeSwitch).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// 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({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
language: "no",
|
||||
github_token_is_set: true,
|
||||
user_consents_to_analytics: true,
|
||||
llm_base_url: "https://test.com",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
agent: "CoActAgent",
|
||||
security_analyzer: "mock-invariant",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("language-input")).toHaveValue("Norsk");
|
||||
expect(screen.getByText("Disconnect from GitHub")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("enable-analytics-switch")).toBeChecked();
|
||||
expect(screen.getByTestId("advanced-settings-switch")).toBeChecked();
|
||||
expect(screen.getByTestId("base-url-input")).toHaveValue(
|
||||
"https://test.com",
|
||||
);
|
||||
expect(screen.getByTestId("llm-custom-model-input")).toHaveValue(
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
);
|
||||
expect(screen.getByTestId("agent-input")).toHaveValue("CoActAgent");
|
||||
expect(
|
||||
screen.getByTestId("enable-confirmation-mode-switch"),
|
||||
).toBeChecked();
|
||||
expect(screen.getByTestId("security-analyzer-input")).toHaveValue(
|
||||
"mock-invariant",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should save the settings when the 'Save Changes' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const languageInput = await screen.findByTestId("language-input");
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = await screen.findByText("Norsk");
|
||||
await user.click(norskOption);
|
||||
|
||||
expect(languageInput).toHaveValue("Norsk");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_api_key: "", // empty because it's not set previously
|
||||
github_token: undefined,
|
||||
language: "no",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should properly save basic LLM model settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
// disable advanced mode
|
||||
const advancedSwitch = await screen.findByTestId(
|
||||
"advanced-settings-switch",
|
||||
);
|
||||
await user.click(advancedSwitch);
|
||||
|
||||
const providerInput = await screen.findByTestId("llm-provider-input");
|
||||
await user.click(providerInput);
|
||||
|
||||
const openaiOption = await screen.findByText("OpenAI");
|
||||
await user.click(openaiOption);
|
||||
|
||||
const modelInput = await screen.findByTestId("llm-model-input");
|
||||
await user.click(modelInput);
|
||||
|
||||
const gpt4Option = await screen.findByText("gpt-4o");
|
||||
await user.click(gpt4Option);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
github_token: undefined,
|
||||
llm_api_key: "", // empty because it's not set previously
|
||||
llm_model: "openai/gpt-4o",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should reset the settings when the 'Reset to defaults' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const languageInput = await screen.findByTestId("language-input");
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = await screen.findByText("Norsk");
|
||||
await user.click(norskOption);
|
||||
|
||||
expect(languageInput).toHaveValue("Norsk");
|
||||
|
||||
const resetButton = screen.getByText("Reset to defaults");
|
||||
await user.click(resetButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
|
||||
// show modal
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// confirm reset
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
const mockCopy: Partial<PostApiSettings> = {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
};
|
||||
delete mockCopy.github_token_is_set;
|
||||
delete mockCopy.unset_github_token;
|
||||
delete mockCopy.user_consents_to_analytics;
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...mockCopy,
|
||||
github_token: undefined, // not set
|
||||
llm_api_key: "", // reset as well
|
||||
});
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should cancel the reset when the 'Cancel' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const resetButton = await screen.findByText("Reset to defaults");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
const cancelButton = within(modal).getByText("Cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsent with true if the save is successful", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
ConsentHandlers,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
renderSettingsScreen();
|
||||
|
||||
const analyticsConsentInput = await screen.findByTestId(
|
||||
"enable-analytics-switch",
|
||||
);
|
||||
|
||||
expect(analyticsConsentInput).not.toBeChecked();
|
||||
await user.click(analyticsConsentInput);
|
||||
expect(analyticsConsentInput).toBeChecked();
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsent with false if the save is successful", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
ConsentHandlers,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
renderSettingsScreen();
|
||||
|
||||
const saveButton = await screen.findByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should not reset analytics consent when resetting to defaults", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const analyticsConsentInput = await screen.findByTestId(
|
||||
"enable-analytics-switch",
|
||||
);
|
||||
expect(analyticsConsentInput).toBeChecked();
|
||||
|
||||
const resetButton = await screen.findByText("Reset to defaults");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_consents_to_analytics: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the security analyzer input if the confirmation mode is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
let securityAnalyzerInput = screen.queryByTestId(
|
||||
"security-analyzer-input",
|
||||
);
|
||||
expect(securityAnalyzerInput).not.toBeInTheDocument();
|
||||
|
||||
const confirmationModeSwitch = await screen.findByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
await user.click(confirmationModeSwitch);
|
||||
|
||||
securityAnalyzerInput = await screen.findByTestId(
|
||||
"security-analyzer-input",
|
||||
);
|
||||
expect(securityAnalyzerInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FIXME: localStorage isn't being set
|
||||
it.skip("should save with ENABLE_DEFAULT_CONDENSER with true if user set the feature flag in local storage", async () => {
|
||||
localStorage.setItem("ENABLE_DEFAULT_CONDENSER", "true");
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enable_default_condenser: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should send an empty LLM API Key if the user submits an empty string", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
const input = await screen.findByTestId("llm-api-key-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ llm_api_key: "" }),
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_api_key: "**********",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const input = await screen.findByTestId("llm-api-key-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ llm_api_key: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should submit the LLM API Key if it is the first time the user sets it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
const input = await screen.findByTestId("llm-api-key-input");
|
||||
await user.type(input, "new-api-key");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ llm_api_key: "new-api-key" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
32
frontend/__tests__/utils/amount-is-valid.test.ts
Normal file
32
frontend/__tests__/utils/amount-is-valid.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
|
||||
describe("amountIsValid", () => {
|
||||
describe("fails", () => {
|
||||
test("when the amount is negative", () => {
|
||||
expect(amountIsValid("-5")).toBe(false);
|
||||
expect(amountIsValid("-25")).toBe(false);
|
||||
});
|
||||
|
||||
test("when the amount is zero", () => {
|
||||
expect(amountIsValid("0")).toBe(false);
|
||||
});
|
||||
|
||||
test("when an empty string is passed", () => {
|
||||
expect(amountIsValid("")).toBe(false);
|
||||
expect(amountIsValid(" ")).toBe(false);
|
||||
});
|
||||
|
||||
test("when a non-numeric value is passed", () => {
|
||||
expect(amountIsValid("abc")).toBe(false);
|
||||
expect(amountIsValid("1abc")).toBe(false);
|
||||
expect(amountIsValid("abc1")).toBe(false);
|
||||
});
|
||||
|
||||
test("when an amount less than the minimum is passed", () => {
|
||||
// test assumes the minimum is 25
|
||||
expect(amountIsValid("24")).toBe(false);
|
||||
expect(amountIsValid("24.99")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
frontend/__tests__/utils/has-advanced-settings-set.test.ts
Normal file
56
frontend/__tests__/utils/has-advanced-settings-set.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, test } from "vitest";
|
||||
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
describe("hasAdvancedSettingsSet", () => {
|
||||
it("should return false by default", () => {
|
||||
expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false);
|
||||
});
|
||||
|
||||
describe("should be true if", () => {
|
||||
test("LLM_BASE_URL is set", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
LLM_BASE_URL: "test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("AGENT is not default value", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
AGENT: "test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("REMOTE_RUNTIME_RESOURCE_FACTOR is not default value", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: 999,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("CONFIRMATION_MODE is true", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
CONFIRMATION_MODE: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("SECURITY_ANALYZER is set", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
SECURITY_ANALYZER: "test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
frontend/__tests__/utils/is-custom-model.test.ts
Normal file
20
frontend/__tests__/utils/is-custom-model.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCustomModel } from "#/utils/is-custom-model";
|
||||
|
||||
describe("isCustomModel", () => {
|
||||
const models = ["anthropic/claude-3.5", "openai/gpt-3.5-turbo", "gpt-4o"];
|
||||
|
||||
it("should return false by default", () => {
|
||||
expect(isCustomModel(models, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true if it is a custom model", () => {
|
||||
expect(isCustomModel(models, "some/model")).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false if it is not a custom model", () => {
|
||||
expect(isCustomModel(models, "anthropic/claude-3.5")).toBe(false);
|
||||
expect(isCustomModel(models, "openai/gpt-3.5-turbo")).toBe(false);
|
||||
expect(isCustomModel(models, "openai/gpt-4o")).toBe(false);
|
||||
});
|
||||
});
|
||||
729
frontend/package-lock.json
generated
729
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.23.0",
|
||||
"version": "0.24.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -13,26 +13,28 @@
|
||||
"@react-router/serve": "^7.1.5",
|
||||
"@react-types/shared": "^3.27.0",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@stripe/react-stripe-js": "^3.1.1",
|
||||
"@stripe/stripe-js": "^5.5.0",
|
||||
"@tanstack/react-query": "^5.66.7",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.4.2",
|
||||
"framer-motion": "^12.4.4",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-browser-languagedetector": "^8.0.3",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.22",
|
||||
"jose": "^5.9.4",
|
||||
"jose": "^5.10.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.216.0",
|
||||
"posthog-js": "^1.219.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -40,7 +42,7 @@
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"vite": "^6.1.0",
|
||||
@@ -49,7 +51,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
|
||||
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev",
|
||||
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=false react-router dev",
|
||||
"dev:mock:saas": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=true react-router dev",
|
||||
"build": "npm run make-i18n && npm run typecheck && react-router build",
|
||||
"start": "npx sirv-cli build/ --single",
|
||||
"test": "vitest run",
|
||||
@@ -85,7 +88,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -93,7 +96,7 @@
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"@vitest/coverage-v8": "^3.0.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -109,8 +112,9 @@
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.4.3",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier": "^3.5.1",
|
||||
"stripe": "^17.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
GetTrajectoryResponse,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/types/settings";
|
||||
import { ApiSettings, PostApiSettings } from "#/types/settings";
|
||||
|
||||
class OpenHands {
|
||||
/**
|
||||
@@ -229,6 +229,7 @@ class OpenHands {
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
selected_repository: selectedRepository,
|
||||
selected_branch: undefined,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
};
|
||||
@@ -266,11 +267,30 @@ class OpenHands {
|
||||
* Save the settings to the server. Only valid settings are saved.
|
||||
* @param settings - the settings to save
|
||||
*/
|
||||
static async saveSettings(settings: Partial<ApiSettings>): Promise<boolean> {
|
||||
static async saveSettings(
|
||||
settings: Partial<PostApiSettings>,
|
||||
): Promise<boolean> {
|
||||
const data = await openHands.post("/api/settings", settings);
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
static async createCheckoutSession(amount: number): Promise<string> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/create-checkout-session",
|
||||
{
|
||||
amount,
|
||||
},
|
||||
);
|
||||
return data.redirect_url;
|
||||
}
|
||||
|
||||
static async getBalance(): Promise<string> {
|
||||
const { data } = await openHands.get<{ credits: string }>(
|
||||
"/api/billing/credits",
|
||||
);
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
static async getGitHubUser(): Promise<GitHubUser> {
|
||||
const response = await openHands.get<GitHubUser>("/api/github/user");
|
||||
|
||||
|
||||
@@ -27,11 +27,10 @@ export function AnalyticsConsentFormModal({
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(analytics);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ContextMenu } from "./context-menu";
|
||||
import { ContextMenuListItem } from "./context-menu-list-item";
|
||||
import { ContextMenuSeparator } from "./context-menu-separator";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface AccountSettingsContextMenuProps {
|
||||
onClickAccountSettings: () => void;
|
||||
onLogout: () => void;
|
||||
onClose: () => void;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export function AccountSettingsContextMenu({
|
||||
onClickAccountSettings,
|
||||
onLogout,
|
||||
onClose,
|
||||
isLoggedIn,
|
||||
@@ -25,15 +22,8 @@ export function AccountSettingsContextMenu({
|
||||
<ContextMenu
|
||||
testId="account-settings-context-menu"
|
||||
ref={ref}
|
||||
className="absolute left-full -top-1 z-10"
|
||||
className="absolute right-full md:left-full -top-1 z-10"
|
||||
>
|
||||
<ContextMenuListItem
|
||||
testId="account-settings-button"
|
||||
onClick={onClickAccountSettings}
|
||||
>
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
@@ -22,7 +22,10 @@ export function ConfirmDeleteModal({
|
||||
<BaseModalTitle title="Are you sure you want to delete this project?" />
|
||||
<BaseModalDescription description="All data associated with this project will be lost." />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div
|
||||
className="flex flex-col gap-2 w-full"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ModalButton
|
||||
onClick={onConfirm}
|
||||
className="bg-danger font-bold"
|
||||
|
||||
@@ -68,6 +68,7 @@ export function ConversationCard({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDelete?.();
|
||||
setContextMenuVisible(false);
|
||||
};
|
||||
|
||||
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
||||
@@ -44,12 +44,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (selectedConversationId) {
|
||||
deleteConversation({ conversationId: selectedConversationId });
|
||||
setConfirmDeleteModalVisible(false);
|
||||
|
||||
if (cid === selectedConversationId) {
|
||||
endSession();
|
||||
}
|
||||
deleteConversation(
|
||||
{ conversationId: selectedConversationId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (cid === selectedConversationId) {
|
||||
endSession();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,7 +114,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
{confirmDeleteModalVisible && (
|
||||
<ConfirmDeleteModal
|
||||
onConfirm={handleConfirmDelete}
|
||||
onConfirm={() => {
|
||||
handleConfirmDelete();
|
||||
setConfirmDeleteModalVisible(false);
|
||||
}}
|
||||
onCancel={() => setConfirmDeleteModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
@@ -10,7 +11,6 @@ import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
|
||||
interface GitHubRepositoriesSuggestionBoxProps {
|
||||
handleSubmit: () => void;
|
||||
@@ -24,8 +24,7 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
user,
|
||||
}: GitHubRepositoriesSuggestionBoxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
@@ -45,39 +44,33 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
if (gitHubAuthUrl) {
|
||||
window.location.href = gitHubAuthUrl;
|
||||
} else {
|
||||
setConnectToGitHubModalOpen(true);
|
||||
navigate("/settings");
|
||||
}
|
||||
};
|
||||
|
||||
const isLoggedIn = !!user;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SuggestionBox
|
||||
title={t(I18nKey.LANDING$OPEN_REPO)}
|
||||
content={
|
||||
isLoggedIn ? (
|
||||
<GitHubRepositorySelector
|
||||
onInputChange={setSearchQuery}
|
||||
onSelect={handleSubmit}
|
||||
publicRepositories={searchedRepos || []}
|
||||
userRepositories={repositories}
|
||||
/>
|
||||
) : (
|
||||
<ModalButton
|
||||
text={t(I18nKey.GITHUB$CONNECT)}
|
||||
icon={<GitHubLogo width={20} height={20} />}
|
||||
className="bg-[#791B80] w-full"
|
||||
onClick={handleConnectToGitHub}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{connectToGitHubModalOpen && (
|
||||
<AccountSettingsModal
|
||||
onClose={() => setConnectToGitHubModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<SuggestionBox
|
||||
title={t(I18nKey.LANDING$OPEN_REPO)}
|
||||
content={
|
||||
isLoggedIn ? (
|
||||
<GitHubRepositorySelector
|
||||
onInputChange={setSearchQuery}
|
||||
onSelect={handleSubmit}
|
||||
publicRepositories={searchedRepos || []}
|
||||
userRepositories={repositories}
|
||||
/>
|
||||
) : (
|
||||
<ModalButton
|
||||
testId="connect-to-github"
|
||||
text={t(I18nKey.GITHUB$CONNECT)}
|
||||
icon={<GitHubLogo width={20} height={20} />}
|
||||
className="bg-[#791B80] w-full"
|
||||
onClick={handleConnectToGitHub}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
92
frontend/src/components/features/payment/payment-form.tsx
Normal file
92
frontend/src/components/features/payment/payment-form.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { cn } from "#/utils/utils";
|
||||
import MoneyIcon from "#/icons/money.svg?react";
|
||||
import { SettingsInput } from "../settings/settings-input";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { HelpLink } from "../settings/help-link";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
|
||||
export function PaymentForm() {
|
||||
const { data: balance, isLoading } = useBalance();
|
||||
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
|
||||
|
||||
const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
|
||||
|
||||
const billingFormAction = async (formData: FormData) => {
|
||||
const amount = formData.get("top-up-input")?.toString();
|
||||
|
||||
if (amount?.trim()) {
|
||||
if (!amountIsValid(amount)) return;
|
||||
|
||||
const float = parseFloat(amount);
|
||||
addBalance({ amount: Number(float.toFixed(2)) });
|
||||
}
|
||||
|
||||
setButtonIsDisabled(true);
|
||||
};
|
||||
|
||||
const handleTopUpInputChange = (value: string) => {
|
||||
setButtonIsDisabled(!amountIsValid(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
action={billingFormAction}
|
||||
data-testid="billing-settings"
|
||||
className="flex flex-col gap-6 px-11 py-9"
|
||||
>
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
Manage Credits
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between w-[680px] bg-[#7F7445] rounded px-3 py-2",
|
||||
"text-[28px] leading-8 -tracking-[0.02em] font-bold",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MoneyIcon width={22} height={14} />
|
||||
<span>Balance</span>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<span data-testid="user-balance">${Number(balance).toFixed(2)}</span>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<SettingsInput
|
||||
testId="top-up-input"
|
||||
name="top-up-input"
|
||||
onChange={handleTopUpInputChange}
|
||||
type="text"
|
||||
label="Top-up amount"
|
||||
placeholder="Specify an amount to top up your credits"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
|
||||
<div className="flex items-center w-[680px] gap-2">
|
||||
<BrandButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isDisabled={isPending || buttonIsDisabled}
|
||||
>
|
||||
Add credit
|
||||
</BrandButton>
|
||||
{isPending && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HelpLink
|
||||
testId="payment-methods-link"
|
||||
href="https://stripe.com/"
|
||||
text="Manage payment methods on"
|
||||
linkText="Stripe"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/features/settings/brand-button.tsx
Normal file
39
frontend/src/components/features/settings/brand-button.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface BrandButtonProps {
|
||||
testId?: string;
|
||||
variant: "primary" | "secondary";
|
||||
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function BrandButton({
|
||||
testId,
|
||||
children,
|
||||
variant,
|
||||
type,
|
||||
isDisabled,
|
||||
className,
|
||||
onClick,
|
||||
}: React.PropsWithChildren<BrandButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
data-testid={testId}
|
||||
disabled={isDisabled}
|
||||
// The type is alreadt passed as a prop to the button component
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-fit p-2 rounded disabled:opacity-30 disabled:cursor-not-allowed",
|
||||
variant === "primary" && "bg-[#C9B974] text-[#0D0F11]",
|
||||
variant === "secondary" && "border border-[#C9B974] text-[#C9B974]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/features/settings/help-link.tsx
Normal file
22
frontend/src/components/features/settings/help-link.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
interface HelpLinkProps {
|
||||
testId: string;
|
||||
text: string;
|
||||
linkText: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) {
|
||||
return (
|
||||
<p data-testid={testId} className="text-xs">
|
||||
{text}{" "}
|
||||
<a
|
||||
href={href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import SuccessIcon from "#/icons/success.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface KeyStatusIconProps {
|
||||
isSet: boolean;
|
||||
}
|
||||
|
||||
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
|
||||
return (
|
||||
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
|
||||
<SuccessIcon
|
||||
className={cn(isSet ? "text-[#A5E75E]" : "text-[#E76A5E]")}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function OptionalTag() {
|
||||
return <span className="text-xs text-[#B7BDC2]">(Optional)</span>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
|
||||
interface SettingsDropdownInputProps {
|
||||
testId: string;
|
||||
label: string;
|
||||
name: string;
|
||||
items: { key: React.Key; label: string }[];
|
||||
showOptionalTag?: boolean;
|
||||
isDisabled?: boolean;
|
||||
defaultSelectedKey?: string;
|
||||
isClearable?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsDropdownInput({
|
||||
testId,
|
||||
label,
|
||||
name,
|
||||
items,
|
||||
showOptionalTag,
|
||||
isDisabled,
|
||||
defaultSelectedKey,
|
||||
isClearable,
|
||||
}: SettingsDropdownInputProps) {
|
||||
return (
|
||||
<label className="flex flex-col gap-2.5 w-[680px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{label}</span>
|
||||
{showOptionalTag && <OptionalTag />}
|
||||
</div>
|
||||
<Autocomplete
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
defaultItems={items}
|
||||
defaultSelectedKey={defaultSelectedKey}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
popoverContent: "bg-[#454545] rounded-xl border border-[#717888]",
|
||||
}}
|
||||
inputProps={{
|
||||
classNames: {
|
||||
inputWrapper:
|
||||
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.key}>{item.label}</AutocompleteItem>
|
||||
)}
|
||||
</Autocomplete>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/features/settings/settings-input.tsx
Normal file
53
frontend/src/components/features/settings/settings-input.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
|
||||
interface SettingsInputProps {
|
||||
testId?: string;
|
||||
name?: string;
|
||||
label: string;
|
||||
type: React.HTMLInputTypeAttribute;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
showOptionalTag?: boolean;
|
||||
isDisabled?: boolean;
|
||||
startContent?: React.ReactNode;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsInput({
|
||||
testId,
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
showOptionalTag,
|
||||
isDisabled,
|
||||
startContent,
|
||||
className,
|
||||
onChange,
|
||||
}: SettingsInputProps) {
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{startContent}
|
||||
<span className="text-sm">{label}</span>
|
||||
{showOptionalTag && <OptionalTag />}
|
||||
</div>
|
||||
<input
|
||||
data-testid={testId}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
name={name}
|
||||
disabled={isDisabled}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-[#B7BDC2]",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import { StyledSwitchComponent } from "./styled-switch-component";
|
||||
|
||||
interface SettingsSwitchProps {
|
||||
testId?: string;
|
||||
name?: string;
|
||||
onToggle?: (value: boolean) => void;
|
||||
defaultIsToggled?: boolean;
|
||||
isBeta?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsSwitch({
|
||||
children,
|
||||
testId,
|
||||
name,
|
||||
onToggle,
|
||||
defaultIsToggled,
|
||||
isBeta,
|
||||
}: React.PropsWithChildren<SettingsSwitchProps>) {
|
||||
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
setIsToggled(value);
|
||||
onToggle?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 w-fit">
|
||||
<input
|
||||
hidden
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
onChange={(e) => handleToggle(e.target.checked)}
|
||||
defaultChecked={defaultIsToggled}
|
||||
/>
|
||||
|
||||
<StyledSwitchComponent isToggled={isToggled} />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{children}</span>
|
||||
{isBeta && (
|
||||
<span className="text-[11px] leading-4 text-[#0D0F11] font-[500] tracking-tighter bg-[#C9B974] px-1 rounded-full">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StyledSwitchComponentProps {
|
||||
isToggled: boolean;
|
||||
}
|
||||
|
||||
export function StyledSwitchComponent({
|
||||
isToggled,
|
||||
}: StyledSwitchComponentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-12 h-6 rounded-xl flex items-center p-1.5 cursor-pointer",
|
||||
isToggled && "justify-end bg-[#C9B974]",
|
||||
!isToggled && "justify-start bg-[#1F2228] border border-[#B7BDC2]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#1F2228] w-3 h-3 rounded-xl",
|
||||
isToggled ? "bg-[#1F2228]" : "bg-[#B7BDC2]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { FaListUl } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import toast from "react-hot-toast";
|
||||
import { NavLink, useLocation } from "react-router";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { UserActions } from "./user-actions";
|
||||
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
|
||||
@@ -10,12 +11,10 @@ import { DocsButton } from "#/components/shared/buttons/docs-button";
|
||||
import { ExitProjectButton } from "#/components/shared/buttons/exit-project-button";
|
||||
import { SettingsButton } from "#/components/shared/buttons/settings-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
@@ -23,58 +22,55 @@ import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
const user = useGitHubUser();
|
||||
const { data: config } = useConfig();
|
||||
const {
|
||||
data: settings,
|
||||
error: settingsError,
|
||||
isError: settingsIsError,
|
||||
isFetching: isFetchingSettings,
|
||||
} = useSettings();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { settings, saveUserSettings } = useCurrentSettings();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] =
|
||||
React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the github token is invalid, open the account settings modal again
|
||||
if (user.isError) {
|
||||
setAccountSettingsModalOpen(true);
|
||||
}
|
||||
}, [user.isError]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// We don't show toast errors for settings in the global error handler
|
||||
// because we have a special case for 404 errors
|
||||
if (
|
||||
if (location.pathname === "/settings") {
|
||||
setSettingsModalIsOpen(false);
|
||||
} else if (
|
||||
!isFetchingSettings &&
|
||||
settingsIsError &&
|
||||
settingsError?.status !== 404
|
||||
) {
|
||||
// We don't show toast errors for settings in the global error handler
|
||||
// because we have a special case for 404 errors
|
||||
toast.error(
|
||||
"Something went wrong while fetching settings. Please reload the page.",
|
||||
);
|
||||
} else if (settingsError?.status === 404) {
|
||||
setSettingsModalIsOpen(true);
|
||||
}
|
||||
}, [settingsError?.status, settingsError, isFetchingSettings]);
|
||||
}, [
|
||||
settingsError?.status,
|
||||
settingsError,
|
||||
isFetchingSettings,
|
||||
location.pathname,
|
||||
]);
|
||||
|
||||
const handleEndSession = () => {
|
||||
dispatch(setCurrentAgentState(AgentState.LOADING));
|
||||
endSession();
|
||||
};
|
||||
|
||||
const handleAccountSettingsModalClose = () => {
|
||||
setAccountSettingsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else await saveUserSettings({ unset_github_token: true });
|
||||
@@ -84,33 +80,47 @@ export function Sidebar() {
|
||||
return (
|
||||
<>
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
|
||||
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
|
||||
<div className="w-[34px] h-[34px] flex items-center justify-center mb-7">
|
||||
<AllHandsLogoButton onClick={handleEndSession} />
|
||||
</div>
|
||||
{user.isLoading && <LoadingSpinner size="small" />}
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
{MULTI_CONVERSATION_UI && (
|
||||
<nav className="flex flex-row md:flex-col items-center justify-between w-full h-auto md:w-auto md:h-full">
|
||||
<div className="flex flex-row md:flex-col items-center gap-[26px]">
|
||||
<div className="flex items-center justify-center">
|
||||
<AllHandsLogoButton onClick={handleEndSession} />
|
||||
</div>
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
<TooltipButton
|
||||
testId="toggle-conversation-panel"
|
||||
tooltip="Conversations"
|
||||
ariaLabel="Conversations"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
>
|
||||
<FaListUl size={22} />
|
||||
<FaListUl
|
||||
size={22}
|
||||
className={cn(
|
||||
conversationPanelIsOpen ? "text-white" : "text-[#9099AC]",
|
||||
)}
|
||||
/>
|
||||
</TooltipButton>
|
||||
)}
|
||||
<DocsButton />
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
{!user.isLoading && (
|
||||
<UserActions
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
<DocsButton />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
`${isActive ? "text-white" : "text-[#9099AC]"} mt-0.5 md:mt-0`
|
||||
}
|
||||
onLogout={handleLogout}
|
||||
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SettingsButton />
|
||||
</NavLink>
|
||||
{!user.isLoading && (
|
||||
<UserActions
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
{user.isLoading && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{conversationPanelIsOpen && (
|
||||
@@ -122,10 +132,7 @@ export function Sidebar() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{accountSettingsModalOpen && (
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{(settingsError?.status === 404 || settingsModalIsOpen) && (
|
||||
{settingsModalIsOpen && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
|
||||
@@ -3,16 +3,11 @@ import { UserAvatar } from "./user-avatar";
|
||||
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
|
||||
|
||||
interface UserActionsProps {
|
||||
onClickAccountSettings: () => void;
|
||||
onLogout: () => void;
|
||||
user?: { avatar_url: string };
|
||||
}
|
||||
|
||||
export function UserActions({
|
||||
onClickAccountSettings,
|
||||
onLogout,
|
||||
user,
|
||||
}: UserActionsProps) {
|
||||
export function UserActions({ onLogout, user }: UserActionsProps) {
|
||||
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
@@ -24,11 +19,6 @@ export function UserActions({
|
||||
setAccountContextMenuIsVisible(false);
|
||||
};
|
||||
|
||||
const handleClickAccountSettings = () => {
|
||||
onClickAccountSettings();
|
||||
closeAccountMenu();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onLogout();
|
||||
closeAccountMenu();
|
||||
@@ -41,7 +31,6 @@ export function UserActions({
|
||||
{accountContextMenuIsVisible && (
|
||||
<AccountSettingsContextMenu
|
||||
isLoggedIn={!!user}
|
||||
onClickAccountSettings={handleClickAccountSettings}
|
||||
onLogout={handleLogout}
|
||||
onClose={closeAccountMenu}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import DefaultUserAvatar from "#/icons/default-user.svg?react";
|
||||
import ProfileIcon from "#/icons/profile.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Avatar } from "./avatar";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
@@ -21,16 +21,17 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
ariaLabel={t(I18nKey.USER$ACCOUNT_SETTINGS)}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2 border-gray-200",
|
||||
"w-8 h-8 rounded-full flex items-center justify-center",
|
||||
isLoading && "bg-transparent",
|
||||
)}
|
||||
>
|
||||
{!isLoading && avatarUrl && <Avatar src={avatarUrl} />}
|
||||
{!isLoading && !avatarUrl && (
|
||||
<DefaultUserAvatar
|
||||
<ProfileIcon
|
||||
aria-label={t(I18nKey.USER$AVATAR_PLACEHOLDER)}
|
||||
width={20}
|
||||
height={20}
|
||||
width={28}
|
||||
height={28}
|
||||
className="text-[#9099AC]"
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ActionButton({
|
||||
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out"
|
||||
type="button"
|
||||
>
|
||||
<span className="relative z-10 group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
|
||||
<span className="relative group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
|
||||
{children}
|
||||
</span>
|
||||
<span className="absolute -inset-[5px] border-2 border-red-400/40 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease-in-out" />
|
||||
|
||||
@@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
|
||||
ariaLabel="All Hands Logo"
|
||||
onClick={onClick}
|
||||
>
|
||||
<AllHandsLogo width={44} height={30} />
|
||||
<AllHandsLogo width={34} height={23} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DocsIcon from "#/icons/docs.svg?react";
|
||||
import DocsIcon from "#/icons/academy.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
@@ -11,7 +11,7 @@ export function DocsButton() {
|
||||
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
|
||||
href="https://docs.all-hands.dev"
|
||||
>
|
||||
<DocsIcon width={28} height={28} />
|
||||
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import NewProjectIcon from "#/icons/new-project.svg?react";
|
||||
import PlusIcon from "#/icons/plus.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
interface ExitProjectButtonProps {
|
||||
@@ -17,7 +17,7 @@ export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
|
||||
onClick={onClick}
|
||||
testId="new-project-button"
|
||||
>
|
||||
<NewProjectIcon width={26} height={26} />
|
||||
<PlusIcon width={28} height={28} className="text-[#9099AC]" />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { FaCog } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SettingsButtonProps {
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TooltipButton
|
||||
testId="settings-button"
|
||||
@@ -16,7 +17,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FaCog size={24} />
|
||||
<SettingsIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
} from "../confirmation-modals/base-modal";
|
||||
import { ModalBody } from "../modal-body";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
import { FormFieldset } from "../../form-fieldset";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { GitHubTokenInput } from "./github-token-input";
|
||||
import { PostSettings } from "#/types/settings";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
|
||||
interface AccountSettingsFormProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AccountSettingsForm({ onClose }: AccountSettingsFormProps) {
|
||||
const { isError: isGitHubError } = useGitHubUser();
|
||||
const { data: config } = useConfig();
|
||||
const { saveUserSettings, settings } = useCurrentSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET;
|
||||
const analyticsConsentValue = !!settings?.USER_CONSENTS_TO_ANALYTICS;
|
||||
const selectedLanguage = settings?.LANGUAGE || "en";
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const ghToken = formData.get("ghToken")?.toString();
|
||||
const language = formData.get("language")?.toString();
|
||||
const analytics = formData.get("analytics")?.toString() === "on";
|
||||
|
||||
const newSettings: Partial<PostSettings> = {};
|
||||
newSettings.user_consents_to_analytics = analytics;
|
||||
|
||||
if (ghToken) newSettings.github_token = ghToken;
|
||||
|
||||
// The form returns the language label, so we need to find the corresponding
|
||||
// language key to save it in the settings
|
||||
if (language) {
|
||||
const languageKey = AvailableLanguages.find(
|
||||
({ label }) => label === language,
|
||||
)?.value;
|
||||
|
||||
if (languageKey) newSettings.LANGUAGE = languageKey;
|
||||
}
|
||||
|
||||
await saveUserSettings(newSettings, {
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(analytics);
|
||||
},
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onDisconnect = async () => {
|
||||
await saveUserSettings({ unset_github_token: true });
|
||||
posthog.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBody testID="account-settings-form">
|
||||
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<BaseModalTitle title={t(I18nKey.ACCOUNT_SETTINGS$TITLE)} />
|
||||
|
||||
{config?.APP_MODE === "saas" && config?.APP_SLUG && (
|
||||
<a
|
||||
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline"
|
||||
>
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</a>
|
||||
)}
|
||||
<FormFieldset
|
||||
id="language"
|
||||
label={t(I18nKey.LANGUAGE$LABEL)}
|
||||
defaultSelectedKey={selectedLanguage}
|
||||
isClearable={false}
|
||||
items={AvailableLanguages.map(({ label, value: key }) => ({
|
||||
key,
|
||||
value: label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
<>
|
||||
<GitHubTokenInput githubTokenIsSet={githubTokenIsSet} />
|
||||
{!githubTokenIsSet && (
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.COMMON$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isGitHubError && (
|
||||
<p className="text-danger text-xs">
|
||||
{t(I18nKey.GITHUB$TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{githubTokenIsSet && !isGitHubError && (
|
||||
<ModalButton
|
||||
testId="disconnect-github"
|
||||
variant="text-like"
|
||||
text={t(I18nKey.BUTTON$DISCONNECT)}
|
||||
onClick={onDisconnect}
|
||||
className="text-danger self-start"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex gap-2 items-center self-start">
|
||||
<input
|
||||
data-testid="analytics-consent"
|
||||
name="analytics"
|
||||
type="checkbox"
|
||||
defaultChecked={analyticsConsentValue}
|
||||
/>
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
testId="save-settings"
|
||||
type="submit"
|
||||
intent="account"
|
||||
text={t(I18nKey.BUTTON$SAVE)}
|
||||
className="bg-[#4465DB]"
|
||||
/>
|
||||
<ModalButton
|
||||
text={t(I18nKey.BUTTON$CLOSE)}
|
||||
onClick={onClose}
|
||||
className="bg-[#737373]"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { AccountSettingsForm } from "./account-settings-form";
|
||||
|
||||
interface AccountSettingsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<AccountSettingsForm onClose={onClose} />
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCheckCircle } from "react-icons/fa";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface GitHubTokenInputProps {
|
||||
githubTokenIsSet: boolean;
|
||||
}
|
||||
|
||||
export function GitHubTokenInput({ githubTokenIsSet }: GitHubTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label htmlFor="ghToken" className="flex flex-col gap-2">
|
||||
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3] flex items-center gap-1">
|
||||
{githubTokenIsSet && (
|
||||
<FaCheckCircle
|
||||
data-testid="github-token-set-checkmark"
|
||||
size={12}
|
||||
className="text-[#00D1B2]"
|
||||
/>
|
||||
)}
|
||||
{t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
<span className="text-[#A3A3A3]">
|
||||
{" "}
|
||||
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
|
||||
</span>
|
||||
</span>
|
||||
{!githubTokenIsSet && (
|
||||
<input
|
||||
data-testid="github-token-input"
|
||||
id="ghToken"
|
||||
name="ghToken"
|
||||
type="password"
|
||||
className="bg-[#27272A] text-xs py-[10px] px-3 rounded"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user