Compare commits

..

1 Commits

Author SHA1 Message Date
Robert Brennan 155b806bff update nikolaik 2025-03-31 13:24:09 -04:00
235 changed files with 2406 additions and 7432 deletions
+2 -4
View File
@@ -145,15 +145,13 @@ jobs:
fi
- name: Set environment variables
env:
REVIEW_BODY: ${{ github.event.review.body || '' }}
run: |
# Handle pull request events first
if [ -n "${{ github.event.pull_request.number }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle pull request review events
elif [ -n "$REVIEW_BODY" ]; then
elif [ -n "${{ github.event.review.body }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle issue comment events that reference a PR
@@ -166,7 +164,7 @@ jobs:
echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
fi
if [ -n "$REVIEW_BODY" ]; then
if [ -n "${{ github.event.review.body }}" ]; then
echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
else
echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV
+1 -2
View File
@@ -19,10 +19,9 @@ jobs:
ref: ${{ github.head_ref }}
- name: Trigger remote job
env:
PR_BRANCH: ${{ github.head_ref }}
run: |
REPO_URL="https://github.com/${{ github.repository }}"
PR_BRANCH="${{ github.head_ref }}"
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"
+1 -1
View File
@@ -118,7 +118,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.31-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.30-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -43,17 +43,17 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
> [!WARNING]
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.31-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.30-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.30-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:
@@ -60,7 +60,25 @@ Félicitations !
## Explication technique
Veuillez consulter le [chapitre sur les images Docker personnalisées dans la documentation d'exécution](https://docs.all-hands.dev/fr/modules/usage/architecture/runtime) pour obtenir des explications plus détaillées.
Lorsqu'une image personnalisée est utilisée pour la première fois, elle ne sera pas trouvée et donc elle sera construite (à l'exécution ultérieure, l'image construite sera trouvée et renvoyée).
L'image personnalisée est construite avec [_build_sandbox_image()](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/docker/image_agnostic_util.py#L29), qui crée un fichier docker en utilisant votre image personnalisée comme base et configure ensuite l'environnement pour OpenHands, comme ceci:
```python
dockerfile_content = (
f'FROM {base_image}\n'
'RUN apt update && apt install -y openssh-server wget sudo\n'
'RUN mkdir -p -m0755 /var/run/sshd\n'
'RUN mkdir -p /openhands && mkdir -p /openhands/logs && chmod 777 /openhands/logs\n'
'RUN wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"\n'
'RUN bash Miniforge3-$(uname)-$(uname -m).sh -b -p /openhands/miniforge3\n'
'RUN bash -c ". /openhands/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"\n'
'RUN echo "export PATH=/openhands/miniforge3/bin:$PATH" >> ~/.bashrc\n'
'RUN echo "export PATH=/openhands/miniforge3/bin:$PATH" >> /openhands/bash.bashrc\n'
).strip()
```
> Remarque: Le nom de l'image est modifié via [_get_new_image_name()](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/docker/image_agnostic_util.py#L63) et c'est ce nom modifié qui sera recherché lors des exécutions ultérieures.
## Dépannage / Erreurs
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
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.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
@@ -60,7 +60,25 @@ base_container_image="custom_image"
## 技術的な説明
詳細な説明については、[実行時ドキュメントのカスタムDockerイメージの章](https://docs.all-hands.dev/ja/modules/usage/architecture/runtime)を参照してください
カスタムイメージが初めて使用される場合、イメージが見つからないため、ビルドされます (その後の実行では、ビルドされたイメージが見つかり、返されます)
カスタムイメージは [_build_sandbox_image()](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/docker/image_agnostic_util.py#L29) でビルドされます。これは、カスタムイメージをベースとして使用して docker ファイルを作成し、次のように OpenHands の環境を設定します:
```python
dockerfile_content = (
f'FROM {base_image}\n'
'RUN apt update && apt install -y openssh-server wget sudo\n'
'RUN mkdir -p -m0755 /var/run/sshd\n'
'RUN mkdir -p /openhands && mkdir -p /openhands/logs && chmod 777 /openhands/logs\n'
'RUN wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"\n'
'RUN bash Miniforge3-$(uname)-$(uname -m).sh -b -p /openhands/miniforge3\n'
'RUN bash -c ". /openhands/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"\n'
'RUN echo "export PATH=/openhands/miniforge3/bin:$PATH" >> ~/.bashrc\n'
'RUN echo "export PATH=/openhands/miniforge3/bin:$PATH" >> /openhands/bash.bashrc\n'
).strip()
```
> 注: イメージ名は [_get_new_image_name()](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/docker/image_agnostic_util.py#L63) で変更され、この変更された名前が後続の実行時に検索されます。
## トラブルシューティング / エラー
@@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.cli
```
@@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -42,7 +42,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
@@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
```bash
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-v $WORKSPACE_BASE:/opt/workspace_base \
@@ -82,5 +82,5 @@ docker network create openhands-network
# 分離されたネットワークで OpenHands を実行
docker run # ... \
--network openhands-network \
docker.all-hands.dev/all-hands-ai/openhands:0.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
@@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.cli
```
@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.main -t "escreva um script bash que imprima oi"
```
@@ -58,17 +58,17 @@
A maneira mais fácil de executar o OpenHands é no Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
Você encontrará o OpenHands em execução em http://localhost:3000!
@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
@@ -1,6 +1,6 @@
# 💿 如何创建自定义 Docker 沙箱
默认的 OpenHands 沙箱包含一个[最小化 ubuntu 配置](https://github.com/All-Hands-AI/OpenHands/blob/main/containers/e2b-sandbox/Dockerfile)。您的应用场景可能需要在默认状态下安装额外的软件。本指南将教您如何通过使用自定义 Docker 映像来实现这一目标。
默认的 OpenHands 沙箱包含一个[最小化 ubuntu 配置](https://github.com/All-Hands-AI/OpenHands/blob/main/containers/sandbox/Dockerfile)。您的应用场景可能需要在默认状态下安装额外的软件。本指南将教您如何通过使用自定义 Docker 映像来实现这一目标。
目前提供两种实现方案:
1. 从 Docker Hub 拉取已有镜像。例如,如果您想安装 `nodejs` ,您可以通过使用 `node:20` 镜像来实现。
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
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.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
你也可以在可脚本化的[无头模式](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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
+2 -2
View File
@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.cli
```
+2 -2
View File
@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+12 -13
View File
@@ -58,31 +58,30 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
You'll find OpenHands running at http://localhost:3000!
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes/docker#connecting-to-your-filesystem),
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes#connecting-to-your-filesystem),
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
or run it on tagged issues with [a GitHub action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
## Setup
After launching OpenHands, you **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
This can be done during the initial settings popup or by selecting the `Settings`
button (gear icon) in the UI.
Upon launching OpenHands, you'll see a Settings page. You **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
These can be changed at any time by selecting the `Settings` button (gear icon) in the UI.
If the required model does not exist in the list, you can toggle `Advanced` options and manually enter it with the correct prefix
in the `Custom Model` text box.
@@ -94,17 +93,17 @@ OpenHands requires an API key to access most language models. Here's how to get
#### Anthropic (Claude)
1. [Create an Anthropic account](https://console.anthropic.com/).
2. [Generate an API key](https://console.anthropic.com/settings/keys).
3. [Set up billing](https://console.anthropic.com/settings/billing).
1. [Create an Anthropic account](https://console.anthropic.com/)
2. [Generate an API key](https://console.anthropic.com/settings/keys)
3. [Set up billing](https://console.anthropic.com/settings/billing)
Consider setting usage limits to control costs.
#### OpenAI
1. [Create an OpenAI account](https://platform.openai.com/).
2. [Generate an API key](https://platform.openai.com/api-keys).
3. [Set up billing](https://platform.openai.com/account/billing/overview).
1. [Create an OpenAI account](https://platform.openai.com/)
2. [Generate an API key](https://platform.openai.com/api-keys)
3. [Set up billing](https://platform.openai.com/account/billing/overview)
Now you're ready to [get started with OpenHands](./getting-started).
+1 -1
View File
@@ -25,7 +25,7 @@ You will need your ChatGPT deployment name which can be found on the deployments
<deployment-name> below.
:::
1. Enable `Advanced` options.
1. Enable `Advanced` options
2. Set the following:
- `Custom Model` to azure/<deployment-name>
- `Base URL` to your Azure API Base URL (e.g. `https://example-endpoint.openai.azure.com`)
+2 -3
View File
@@ -9,9 +9,9 @@ recommendations for model selection. Our latest benchmarking results can be foun
Based on these findings and community feedback, the following models have been verified to work reasonably well with OpenHands:
- anthropic/claude-3-7-sonnet-20250219 (recommended)
- anthropic/claude-3-5-sonnet-20241022 (recommended)
- anthropic/claude-3-5-haiku-20241022
- deepseek/deepseek-chat
- OpenHands LM
- gpt-4o
:::warning
@@ -56,7 +56,6 @@ We have a few guides for running OpenHands with specific model providers:
- [Azure](llms/azure-llms)
- [Google](llms/google-llms)
- [Groq](llms/groq)
- [Local LLMs with SGLang or vLLM](llms/../local-llms.md)
- [LiteLLM Proxy](llms/litellm-proxy)
- [OpenAI](llms/openai-llms)
- [OpenRouter](llms/openrouter)
+155 -46
View File
@@ -1,83 +1,192 @@
# Local LLM with SGLang or vLLM
# Local LLM with Ollama
:::warning
When using a Local LLM, OpenHands may have limited functionality.
It is highly recommended that you use GPUs to serve local models for optimal experience.
:::
## News
Ensure that you have the Ollama server up and running.
For detailed startup instructions, refer to [here](https://github.com/ollama/ollama).
- 2025/03/31: We released an open model OpenHands LM v0.1 32B that achieves 37.1% on SWE-Bench Verified
([blog](https://www.all-hands.dev/blog/introducing-openhands-lm-32b----a-strong-open-coding-agent-model), [model](https://huggingface.co/all-hands/openhands-lm-32b-v0.1)).
This guide assumes you've started ollama with `ollama serve`. If you're running ollama differently (e.g. inside docker), the instructions might need to be modified. Please note that if you're running WSL the default ollama configuration blocks requests from docker containers. See [here](#configuring-ollama-service-wsl-en).
## Download the Model from Huggingface
## Pull Models
For example, to download [OpenHands LM 32B v0.1](https://huggingface.co/all-hands/openhands-lm-32b-v0.1):
Ollama model names can be found [here](https://ollama.com/library). For a small example, you can use
the `codellama:7b` model. Bigger models will generally perform better.
```bash
huggingface-cli download all-hands/openhands-lm-32b-v0.1 --local-dir my_folder/openhands-lm-32b-v0.1
ollama pull codellama:7b
```
## Create an OpenAI-Compatible Endpoint With a Model Serving Framework
### Serving with SGLang
- Install SGLang following [the official documentation](https://docs.sglang.ai/start/install.html).
- Example launch command for OpenHands LM 32B (with at least 2 GPUs):
you can check which models you have downloaded like this:
```bash
SGLANG_ALLOW_OVERWRITE_LONGER_CONTEXT_LEN=1 python3 -m sglang.launch_server \
--model my_folder/openhands-lm-32b-v0.1 \
--served-model-name openhands-lm-32b-v0.1 \
--port 8000 \
--tp 2 --dp 1 \
--host 0.0.0.0 \
--api-key mykey --context-length 131072
~$ ollama list
NAME ID SIZE MODIFIED
codellama:7b 8fdf8f752f6e 3.8 GB 6 weeks ago
mistral:7b-instruct-v0.2-q4_K_M eb14864c7427 4.4 GB 2 weeks ago
starcoder2:latest f67ae0f64584 1.7 GB 19 hours ago
```
### Serving with vLLM
## Run OpenHands with Docker
- Install vLLM following [the official documentation](https://docs.vllm.ai/en/latest/getting_started/installation.html).
- Example launch command for OpenHands LM 32B (with at least 2 GPUs):
### Start OpenHands
Use the instructions [here](../getting-started) to start OpenHands using Docker.
But when running `docker run`, you'll need to add a few more arguments:
```bash
vllm serve my_folder/openhands-lm-32b-v0.1 \
--host 0.0.0.0 --port 8000 \
--api-key mykey \
--tensor-parallel-size 2 \
--served-model-name openhands-lm-32b-v0.1
--enable-prefix-caching
docker run # ...
--add-host host.docker.internal:host-gateway \
-e LLM_OLLAMA_BASE_URL="http://host.docker.internal:11434" \
# ...
```
## Run and Configure OpenHands
LLM_OLLAMA_BASE_URL is optional. If you set it, it will be used to show
the available installed models in the UI.
### Run OpenHands
#### Using Docker
### Configure the Web Application
Run OpenHands using [the official docker run command](../installation#start-the-app).
When running `openhands`, you'll need to set the following in the OpenHands UI through the Settings:
- the model to "ollama/<model-name>"
- the base url to `http://host.docker.internal:11434`
- the API key is optional, you can use any string, such as `ollama`.
#### Using Development Mode
## Run OpenHands in Development Mode
### Build from Source
Use the instructions in [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md) to build OpenHands.
Ensure `config.toml` exists by running `make setup-config` which will create one for you. In the `config.toml`, enter the following:
Make sure `config.toml` is there by running `make setup-config` which will create one for you. In `config.toml`, enter the followings:
```
[core]
workspace_base="/path/to/your/workspace"
workspace_base="./workspace"
[llm]
embedding_model="local"
ollama_base_url="http://localhost:8000"
ollama_base_url="http://localhost:11434"
```
Start OpenHands using `make run`.
Done! Now you can start OpenHands by: `make run`. You now should be able to connect to `http://localhost:3000/`
### Configure OpenHands
### Configure the Web Application
Once OpenHands is running, you'll need to set the following in the OpenHands UI through the Settings:
1. Enable `Advanced` options.
2. Set the following:
- `Custom Model` to `openai/<served-model-name>` (e.g. `openai/openhands-lm-32b-v0.1`)
- `Base URL` to `http://host.docker.internal:8000`
- `API key` to the same string you set when serving the model (e.g. `mykey`)
In the OpenHands UI, click on the Settings wheel in the bottom-left corner.
Then in the `Model` input, enter `ollama/codellama:7b`, or the name of the model you pulled earlier.
If it doesnt show up in the dropdown, enable `Advanced Settings` and type it in. Please note: you need the model name as listed by `ollama list`, with the prefix `ollama/`.
In the API Key field, enter `ollama` or any value, since you don't need a particular key.
In the Base URL field, enter `http://localhost:11434`.
And now you're ready to go!
## Configuring the ollama service (WSL) {#configuring-ollama-service-wsl-en}
The default configuration for ollama in WSL only serves localhost. This means you can't reach it from a docker container. eg. it wont work with OpenHands. First let's test that ollama is running correctly.
```bash
ollama list # get list of installed models
curl http://localhost:11434/api/generate -d '{"model":"[NAME]","prompt":"hi"}'
#ex. curl http://localhost:11434/api/generate -d '{"model":"codellama:7b","prompt":"hi"}'
#ex. curl http://localhost:11434/api/generate -d '{"model":"codellama","prompt":"hi"}' #the tag is optional if there is only one
```
Once that is done, test that it allows "outside" requests, like those from inside a docker container.
```bash
docker ps # get list of running docker containers, for most accurate test choose the OpenHands sandbox container.
docker exec [CONTAINER ID] curl http://host.docker.internal:11434/api/generate -d '{"model":"[NAME]","prompt":"hi"}'
#ex. docker exec cd9cc82f7a11 curl http://host.docker.internal:11434/api/generate -d '{"model":"codellama","prompt":"hi"}'
```
## Fixing it
Now let's make it work. Edit /etc/systemd/system/ollama.service with sudo privileges. (Path may vary depending on linux flavor)
```bash
sudo vi /etc/systemd/system/ollama.service
```
or
```bash
sudo nano /etc/systemd/system/ollama.service
```
In the [Service] bracket add these lines
```
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_ORIGINS=*"
```
Then save, reload the configuration and restart the service.
```bash
sudo systemctl daemon-reload
sudo systemctl restart ollama
```
Finally test that ollama is accessible from within the container
```bash
ollama list # get list of installed models
docker ps # get list of running docker containers, for most accurate test choose the OpenHands sandbox container.
docker exec [CONTAINER ID] curl http://host.docker.internal:11434/api/generate -d '{"model":"[NAME]","prompt":"hi"}'
```
# Local LLM with LM Studio
Steps to set up LM Studio:
1. Open LM Studio
2. Go to the Local Server tab.
3. Click the "Start Server" button.
4. Select the model you want to use from the dropdown.
Set the following configs:
```bash
LLM_MODEL="openai/lmstudio"
LLM_BASE_URL="http://localhost:1234/v1"
CUSTOM_LLM_PROVIDER="openai"
```
### Docker
```bash
docker run # ...
-e LLM_MODEL="openai/lmstudio" \
-e LLM_BASE_URL="http://host.docker.internal:1234/v1" \
-e CUSTOM_LLM_PROVIDER="openai" \
# ...
```
You should now be able to connect to `http://localhost:3000/`
In the development environment, you can set the following configs in the `config.toml` file:
```
[core]
workspace_base="./workspace"
[llm]
model="openai/lmstudio"
base_url="http://localhost:1234/v1"
custom_llm_provider="openai"
```
Done! Now you can start OpenHands by: `make run` without Docker. You now should be able to connect to `http://localhost:3000/`
# Note
For WSL, run the following commands in cmd to set up the networking mode to mirrored:
```
python -c "print('[wsl2]\nnetworkingMode=mirrored',file=open(r'%UserProfile%\.wslconfig','w'))"
wsl --shutdown
```
+10 -8
View File
@@ -3,20 +3,22 @@
A Runtime is an environment where the OpenHands agent can edit files and run
commands.
By default, OpenHands uses a [Docker-based runtime](./runtimes/docker), running on your local computer.
By default, OpenHands uses a Docker-based runtime, running on your local computer.
This means you only have to pay for the LLM you're using, and your code is only ever sent to the LLM.
We also support other runtimes, which are typically managed by third-parties.
We also support "remote" runtimes, which are typically managed by third-parties.
They can make setup a bit simpler and more scalable, especially
if you're running many OpenHands conversations in parallel (e.g. to do evaluation).
Additionally, we provide a [Local Runtime](./runtimes/local) that runs directly on your machine without Docker,
Additionally, we provide a "local" runtime that runs directly on your machine without Docker,
which can be useful in controlled environments like CI pipelines.
## Available Runtimes
OpenHands supports several different runtime environments:
- [Docker Runtime](./runtimes/docker.md) - The default runtime that uses Docker containers for isolation (recommended for most users).
- [OpenHands Remote Runtime](./runtimes/remote.md) - Cloud-based runtime for parallel execution (beta).
- [Modal Runtime](./runtimes/modal.md) - Runtime provided by our partners at Modal.
- [Daytona Runtime](./runtimes/daytona.md) - Runtime provided by Daytona.
- [Local Runtime](./runtimes/local.md) - Direct execution on your local machine without Docker.
- [Docker Runtime](./runtimes/docker.md) - The default runtime that uses Docker containers for isolation (recommended for most users)
- [OpenHands Remote Runtime](./runtimes/remote.md) - Cloud-based runtime for parallel execution (beta)
- [Modal Runtime](./runtimes/modal.md) - Runtime provided by our partners at Modal
- [Daytona Runtime](./runtimes/daytona.md) - Runtime provided by Daytona
- [Local Runtime](./runtimes/local.md) - Direct execution on your local machine without Docker
+11 -9
View File
@@ -8,7 +8,7 @@ that contains our Runtime server, as well as some basic utilities for Python and
You can also [build your own runtime image](../how-to/custom-sandbox-guide).
## Connecting to Your filesystem
A useful feature is the ability to connect to your local filesystem. To mount your filesystem into the runtime:
One useful feature here is the ability to connect to your local filesystem. To mount your filesystem into the runtime:
1. Set `WORKSPACE_BASE`:
```bash
@@ -40,20 +40,20 @@ but seems to work well on most systems.
## Hardened Docker Installation
When deploying OpenHands in environments where security is a priority, you should consider implementing a hardened
Docker configuration. This section provides recommendations for securing your OpenHands Docker deployment beyond the default configuration.
When deploying OpenHands in environments where security is a priority, you should consider implementing a hardened Docker configuration. This section provides recommendations for securing your OpenHands Docker deployment beyond the default configuration.
### Security Considerations
The default Docker configuration in the README is designed for ease of use on a local development machine. If you're
running on a public network (e.g. airport WiFi), you should implement additional security measures.
The default Docker configuration in the README is designed for ease of use on a local development machine. If you're running on a public network (e.g. airport WiFi),
you should implement additional security measures.
### Network Binding Security
By default, OpenHands binds to all network interfaces (`0.0.0.0`), which can expose your instance to all networks the
host is connected to. For a more secure setup:
By default, OpenHands binds to all network interfaces (`0.0.0.0`), which can expose your instance to all networks the host is connected to. For a more secure setup:
1. **Restrict Network Binding**: Use the `runtime_binding_address` configuration to restrict which network interfaces OpenHands listens on:
1. **Restrict Network Binding**:
Use the `runtime_binding_address` configuration to restrict which network interfaces OpenHands listens on:
```bash
docker run # ...
@@ -63,7 +63,9 @@ host is connected to. For a more secure setup:
This configuration ensures OpenHands only listens on the loopback interface (`127.0.0.1`), making it accessible only from the local machine.
2. **Secure Port Binding**: Modify the `-p` flag to bind only to localhost instead of all interfaces:
2. **Secure Port Binding**:
Modify the `-p` flag to bind only to localhost instead of all interfaces:
```bash
docker run # ... \
+7 -9
View File
@@ -1,26 +1,23 @@
# Local Runtime
The Local Runtime allows the OpenHands agent to execute actions directly on your local machine without using Docker.
This runtime is primarily intended for controlled environments like CI pipelines or testing scenarios where Docker is not available.
The Local Runtime allows the OpenHands agent to execute actions directly on your local machine without using Docker. This runtime is primarily intended for controlled environments like CI pipelines or testing scenarios where Docker is not available.
:::caution
**Security Warning**: The Local Runtime runs without any sandbox isolation. The agent can directly access and modify
files on your machine. Only use this runtime in controlled environments or when you fully understand the security implications.
**Security Warning**: The Local Runtime runs without any sandbox isolation. The agent can directly access and modify files on your machine. Only use this runtime in controlled environments or when you fully understand the security implications.
:::
## Prerequisites
Before using the Local Runtime, ensure that:
1. You can run OpenHands using the [Development workflow](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
1. You have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
2. tmux is available on your system.
## Configuration
To use the Local Runtime, besides required configurations like the LLM provider, model and API key, you'll need to set
the following options via environment variables or the [config.toml file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) when starting OpenHands:
To use the Local Runtime, besides required configurations like the model, API key, you'll need to set the following options via environment variables or the [config.toml file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) when starting OpenHands:
Via environment variables:
- Via environment variables:
```bash
# Required
@@ -30,7 +27,7 @@ export RUNTIME=local
export WORKSPACE_BASE=/path/to/your/workspace
```
Via `config.toml`:
- Via `config.toml`:
```toml
[core]
@@ -62,3 +59,4 @@ The Local Runtime is particularly useful for:
- CI/CD pipelines where Docker is not available.
- Testing and development of OpenHands itself.
- Environments where container usage is restricted.
- Scenarios where direct file system access is required.
+1 -1
View File
@@ -9,5 +9,5 @@ You'll then need to set the following environment variables when starting OpenHa
docker run # ...
-e RUNTIME=modal \
-e MODAL_API_TOKEN_ID="your-id" \
-e MODAL_API_TOKEN_SECRET="modal-api-key" \
-e MODAL_API_TOKEN_SECRET="your-secret" \
```
+3 -6
View File
@@ -1,9 +1,6 @@
# OpenHands Remote Runtime
:::note
This runtime is specifically designed for agent evaluation purposes only through the
[OpenHands evaluation harness](https://github.com/All-Hands-AI/OpenHands/tree/main/evaluation). It should not be used to launch production OpenHands applications.
:::
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to launch runtimes in parallel in the cloud.
Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to launch runtimes
in parallel in the cloud. Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
NOTE: This runtime is specifically designed for agent evaluation purposes only through [OpenHands evaluation harness](https://github.com/All-Hands-AI/OpenHands/tree/main/evaluation). It should not be used to launch production OpenHands applications.
-5
View File
@@ -141,11 +141,6 @@ const sidebars: SidebarsConfig = {
label: 'Groq',
id: 'usage/llms/groq',
},
{
type: 'doc',
label: 'Local LLMs with SGLang or vLLM',
id: 'usage/llms/local-llms',
},
{
type: 'doc',
label: 'LiteLLM Proxy',
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -156,7 +156,7 @@ For example, to evaluate a specific instance with a custom dataset and split:
./evaluation/benchmarks/swe_bench/scripts/eval_infer.sh $YOUR_OUTPUT_JSONL instance_123 princeton-nlp/SWE-bench test
```
> You can also pass in a JSONL with [SWE-Bench format](https://github.com/SWE-bench/SWE-bench/blob/main/assets/evaluation.md#-creating-predictions) to `./evaluation/benchmarks/swe_bench/scripts/eval_infer.sh`, where each line is a JSON of `{"model_patch": "XXX", "model_name_or_path": "YYY", "instance_id": "ZZZ"}`.
> You can also pass in a JSONL with [SWE-Bench format](https://github.com/princeton-nlp/SWE-bench/blob/main/tutorials/evaluation.md#-creating-predictions) to `./evaluation/benchmarks/swe_bench/scripts/eval_infer.sh`, where each line is a JSON of `{"model_patch": "XXX", "model_name_or_path": "YYY", "instance_id": "ZZZ"}`.
The final results will be saved to `evaluation/evaluation_outputs/outputs/swe_bench/CodeActAgent/gpt-4-1106-preview_maxiter_50_N_v1.0/` with the following files/directory:
@@ -386,21 +386,6 @@ def complete_runtime(
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 ctrl+z it...')
action = CmdRunAction(command='C-z')
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)}',
@@ -40,9 +40,6 @@ def get_config(
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
sandbox_config.enable_auto_lint = True
# If the web services are running on the host machine, this must be set to True
sandbox_config.use_host_network = True
config = AppConfig(
run_as_openhands=False,
max_budget_per_task=4,
@@ -145,7 +145,7 @@ while IFS= read -r task_image; do
docker pull $task_image
# Build the Python command
COMMAND="poetry run python -m evaluation.benchmarks.the_agent_company.run_infer \
COMMAND="poetry run python run_infer.py \
--agent-llm-config \"$AGENT_LLM_CONFIG\" \
--env-llm-config \"$ENV_LLM_CONFIG\" \
--outputs-path \"$OUTPUTS_PATH\" \
-5
View File
@@ -521,11 +521,6 @@ def compatibility_for_eval_history_pairs(
def is_fatal_evaluation_error(error: str | None) -> bool:
"""
The AgentController class overrides last error for certain exceptions
We want to ensure those exeption do not overlap with fatal exceptions defined here
This is because we do a comparisino against the stringified error
"""
if not error:
return False
@@ -57,6 +57,6 @@ describe("Browser", () => {
});
expect(screen.getByText("https://example.com")).toBeInTheDocument();
expect(screen.getByAltText("BROWSER$SCREENSHOT_ALT")).toBeInTheDocument();
expect(screen.getByAltText(/browser screenshot/i)).toBeInTheDocument();
});
});
@@ -1,40 +0,0 @@
import { render, screen } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("CopyToClipboardButton", () => {
test("should have localized aria-label", () => {
render(
<CopyToClipboardButton
isHidden={false}
isDisabled={false}
onClick={() => {}}
mode="copy"
/>
);
const button = screen.getByTestId("copy-to-clipboard");
expect(button).toHaveAttribute("aria-label", "BUTTON$COPY");
});
test("should have localized aria-label when copied", () => {
render(
<CopyToClipboardButton
isHidden={false}
isDisabled={false}
onClick={() => {}}
mode="copied"
/>
);
const button = screen.getByTestId("copy-to-clipboard");
expect(button).toHaveAttribute("aria-label", "BUTTON$COPIED");
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
import { useAuth } from "#/context/auth-context";
@@ -19,50 +19,23 @@ vi.mock("#/context/auth-context", () => ({
useAuth: vi.fn(),
}));
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"ACTION$PUSH_TO_BRANCH": "Push to Branch",
"ACTION$PUSH_CREATE_PR": "Push & Create PR",
"ACTION$PUSH_CHANGES_TO_PR": "Push Changes to PR"
};
return translations[key] || key;
},
}),
}));
describe("ActionSuggestions", () => {
// Setup mocks for each test
beforeEach(() => {
vi.clearAllMocks();
vi.clearAllMocks();
(useAuth as any).mockReturnValue({
providersAreSet: true,
});
(useAuth as any).mockReturnValue({
githubTokenIsSet: true,
});
(useSelector as any).mockReturnValue({
selectedRepository: "test-repo",
});
(useSelector as any).mockReturnValue({
selectedRepository: "test-repo",
});
it("should render both GitHub buttons when GitHub token is set and repository is selected", () => {
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
// Find all buttons with data-testid="suggestion"
const buttons = screen.getAllByTestId("suggestion");
// Check if we have at least 2 buttons
expect(buttons.length).toBeGreaterThanOrEqual(2);
// Check if the buttons contain the expected text
const pushButton = buttons.find((button) =>
button.textContent?.includes("Push to Branch"),
);
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
const pushButton = screen.getByRole("button", { name: "Push to Branch" });
const prButton = screen.getByRole("button", { name: "Push & Create PR" });
expect(pushButton).toBeInTheDocument();
expect(prButton).toBeInTheDocument();
@@ -70,12 +43,13 @@ describe("ActionSuggestions", () => {
it("should not render buttons when GitHub token is not set", () => {
(useAuth as any).mockReturnValue({
providersAreSet: false,
githubTokenIsSet: false,
});
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument();
});
it("should not render buttons when no repository is selected", () => {
@@ -85,20 +59,17 @@ describe("ActionSuggestions", () => {
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument();
});
it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => {
// This test verifies that the prompts are different in the component
const component = render(
<ActionSuggestions onSuggestionsClick={() => {}} />,
);
const component = render(<ActionSuggestions onSuggestionsClick={() => {}} />);
// Get the component instance to access the internal values
const pushBranchPrompt =
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
const createPRPrompt =
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes. If a pull request template exists in the repository, please follow it when creating the PR description.";
const pushBranchPrompt = "Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
const createPRPrompt = "Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.";
// Verify the prompts are different
expect(pushBranchPrompt).not.toEqual(createPRPrompt);
@@ -15,31 +15,6 @@ import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
import { clickOnEditButton } from "./utils";
// We'll use the actual i18next implementation but override the translation function
import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
// Mock the t function to return our custom translations
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"CONVERSATION$CREATED": "Created",
"CONVERSATION$AGO": "ago",
"CONVERSATION$UPDATED": "Updated"
};
return translations[key] || key;
},
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
describe("ConversationCard", () => {
const onClick = vi.fn();
const onDelete = vi.fn();
@@ -72,18 +47,12 @@ describe("ConversationCard", () => {
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
const card = screen.getByTestId("conversation-card");
within(card).getByText("Conversation 1");
// Just check that the card contains the expected text content
expect(card).toHaveTextContent("Created");
expect(card).toHaveTextContent("ago");
// Use a regex to match the time part since it might have whitespace
const timeRegex = new RegExp(formatTimeDelta(new Date("2021-10-01T12:00:00Z")));
expect(card).toHaveTextContent(timeRegex);
within(card).getByText(expectedDate);
});
it("should render the selectedRepository if available", () => {
@@ -372,7 +341,7 @@ describe("ConversationCard", () => {
await user.click(displayCostButton);
// Verify if metrics modal is displayed by checking for the modal content
expect(screen.getByTestId("metrics-modal")).toBeInTheDocument();
expect(screen.getByText("Metrics Information")).toBeInTheDocument();
});
it("should not display the edit or delete options if the handler is not provided", async () => {
@@ -135,10 +135,10 @@ describe("ConversationPanel", () => {
await user.click(deleteButton);
// Cancel the deletion
const cancelButton = screen.getByRole("button", { name: /cancel/i });
const cancelButton = screen.getByText("Cancel");
await user.click(cancelButton);
expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
// Ensure the conversation is not deleted
cards = await screen.findAllByTestId("conversation-card");
@@ -172,10 +172,10 @@ describe("ConversationPanel", () => {
await user.click(deleteButton);
// Confirm the deletion
const confirmButton = screen.getByRole("button", { name: /confirm/i });
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument();
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
// Wait for the cards to update with a longer timeout
await waitFor(() => {
@@ -239,10 +239,10 @@ describe("ConversationPanel", () => {
await user.click(deleteButton);
// Confirm the deletion
const confirmButton = screen.getByRole("button", { name: /confirm/i });
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument();
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
// Wait for the cards to update
await waitFor(() => {
@@ -1,17 +1,16 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitRepositorySelector } from "#/components/features/git/git-repo-selector";
import { GitHubRepositorySelector } from "#/components/features/github/github-repo-selector";
import OpenHands from "#/api/open-hands";
import { Provider } from "#/types/settings";
describe("GitRepositorySelector", () => {
describe("GitHubRepositorySelector", () => {
const onInputChangeMock = vi.fn();
const onSelectMock = vi.fn();
it("should render the search input", () => {
renderWithProviders(
<GitRepositorySelector
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
@@ -20,7 +19,7 @@ describe("GitRepositorySelector", () => {
);
expect(
screen.getByPlaceholderText("LANDING$SELECT_GIT_REPO"),
screen.getByPlaceholderText("LANDING$SELECT_REPO"),
).toBeInTheDocument();
});
@@ -38,7 +37,7 @@ describe("GitRepositorySelector", () => {
});
renderWithProviders(
<GitRepositorySelector
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
@@ -54,25 +53,23 @@ describe("GitRepositorySelector", () => {
{
id: 1,
full_name: "test/repo1",
git_provider: "github" as Provider,
stargazers_count: 100,
},
{
id: 2,
full_name: "test/repo2",
git_provider: "github" as Provider,
stargazers_count: 200,
},
];
const searchPublicRepositoriesSpy = vi.spyOn(
OpenHands,
"searchGitRepositories",
"searchGitHubRepositories",
);
searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos);
renderWithProviders(
<GitRepositorySelector
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
@@ -63,7 +63,7 @@ describe("PaymentForm", () => {
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "50.12");
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.12);
@@ -76,7 +76,7 @@ describe("PaymentForm", () => {
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "50.125456");
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13);
@@ -86,7 +86,7 @@ describe("PaymentForm", () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
const topUpButton = screen.getByText("Add credit");
expect(topUpButton).toBeDisabled();
const topUpInput = await screen.findByTestId("top-up-input");
@@ -102,7 +102,7 @@ describe("PaymentForm", () => {
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "50.12");
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(topUpButton).toBeDisabled();
@@ -116,7 +116,7 @@ describe("PaymentForm", () => {
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "-50.12");
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
@@ -129,7 +129,7 @@ describe("PaymentForm", () => {
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, " ");
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
@@ -142,7 +142,7 @@ describe("PaymentForm", () => {
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "abc");
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
@@ -155,7 +155,7 @@ describe("PaymentForm", () => {
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "9"); // test assumes the minimum is 10
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
@@ -24,7 +24,7 @@ describe("WaitlistModal", () => {
const user = userEvent.setup();
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
const checkbox = screen.getByRole("checkbox");
const button = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
const button = screen.getByRole("button", { name: "Connect to GitHub" });
expect(button).toBeDisabled();
@@ -45,7 +45,7 @@ describe("WaitlistModal", () => {
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
const button = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
const button = screen.getByRole("button", { name: "Connect to GitHub" });
await user.click(button);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
@@ -36,7 +36,7 @@ describe("UserAvatar", () => {
/>,
);
expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument();
expect(screen.getByAltText("user avatar")).toBeInTheDocument();
expect(
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
).not.toBeInTheDocument();
@@ -63,6 +63,6 @@ describe("UserAvatar", () => {
/>,
);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument();
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
});
});
@@ -8,7 +8,6 @@ import {
WsClientProvider,
useWsClient,
} from "#/context/ws-client-provider";
import { AuthProvider } from "#/context/auth-context";
describe("Propagate error message", () => {
it("should do nothing when no message was passed from server", () => {
@@ -91,11 +90,9 @@ describe("WsClientProvider", () => {
const { getByText } = render(<TestComponent />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider initialProviderTokens={[]}>
<WsClientProvider conversationId="test-conversation-id">
{children}
</WsClientProvider>
</AuthProvider>
<WsClientProvider conversationId="test-conversation-id">
{children}
</WsClientProvider>
</QueryClientProvider>
),
});
@@ -18,7 +18,7 @@ describe("useSaveSettings", () => {
),
});
result.current.mutate({ llm_api_key: "" });
result.current.mutate({ LLM_API_KEY: "" });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -27,7 +27,7 @@ describe("useSaveSettings", () => {
);
});
result.current.mutate({ llm_api_key: null });
result.current.mutate({ LLM_API_KEY: null });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -88,6 +88,6 @@ describe("Settings Billing", () => {
await user.click(credits);
const billingSection = await screen.findByTestId("billing-settings");
within(billingSection).getByText("PAYMENT$MANAGE_CREDITS");
within(billingSection).getByText("Manage Credits");
});
});
+82 -46
View File
@@ -10,18 +10,12 @@ import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
import AccountSettings from "#/routes/account-settings";
import { Provider } from "#/types/settings";
const toggleAdvancedSettings = async (user: UserEvent) => {
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
await user.click(advancedSwitch);
};
const mock_provider_tokens_are_set: Record<Provider, boolean> = {
github: true,
gitlab: false,
};
describe("Settings Screen", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
@@ -64,11 +58,11 @@ describe("Settings Screen", () => {
renderSettingsScreen();
await waitFor(() => {
// Use queryAllByText to handle multiple elements with the same text
expect(screen.queryAllByText("SETTINGS$LLM_SETTINGS")).not.toHaveLength(0);
screen.getByText("ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS");
screen.getByText("BUTTON$RESET_TO_DEFAULTS");
screen.getByText("BUTTON$SAVE");
screen.getByText("LLM Settings");
screen.getByText("GitHub Settings");
screen.getByText("Additional Settings");
screen.getByText("Reset to defaults");
screen.getByText("Save Changes");
});
});
@@ -100,6 +94,7 @@ describe("Settings Screen", () => {
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();
@@ -120,7 +115,7 @@ describe("Settings Screen", () => {
it("should set '<hidden>' placeholder if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: mock_provider_tokens_are_set,
github_token_is_set: true,
});
renderSettingsScreen();
@@ -134,7 +129,7 @@ describe("Settings Screen", () => {
it("should render an indicator if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: mock_provider_tokens_are_set,
github_token_is_set: true,
});
renderSettingsScreen();
@@ -150,7 +145,50 @@ describe("Settings Screen", () => {
}
});
// Tests for DISCONNECT_FROM_GITHUB button removed as the button is no longer included in main
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({
@@ -165,7 +203,7 @@ describe("Settings Screen", () => {
renderSettingsScreen();
const button = screen.queryByText("GITHUB$CONFIGURE_REPOS");
const button = screen.queryByText("Configure GitHub Repositories");
expect(button).not.toBeInTheDocument();
});
@@ -182,7 +220,7 @@ describe("Settings Screen", () => {
});
renderSettingsScreen();
await screen.findByText("GITHUB$CONFIGURE_REPOS");
await screen.findByText("Configure GitHub Repositories");
});
it("should not render the GitHub token input if SaaS mode", async () => {
@@ -211,6 +249,7 @@ describe("Settings Screen", () => {
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"));
@@ -226,7 +265,7 @@ describe("Settings Screen", () => {
const input = await screen.findByTestId("github-token-input");
await user.type(input, "invalid-token");
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
llmProviderInput = await screen.findByTestId("llm-provider-input");
@@ -353,7 +392,7 @@ describe("Settings Screen", () => {
it("should render an indicator if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key_set: true,
llm_api_key: "**********",
});
renderSettingsScreen();
@@ -374,7 +413,7 @@ describe("Settings Screen", () => {
it("should set '<hidden>' placeholder if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key_set: true,
llm_api_key: "**********",
});
renderSettingsScreen();
@@ -506,7 +545,7 @@ describe("Settings Screen", () => {
const option = await screen.findByText("2x (4 core, 16G)");
await user.click(option);
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
@@ -522,7 +561,7 @@ describe("Settings Screen", () => {
await toggleAdvancedSettings(user);
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
await waitFor(() => {
@@ -553,7 +592,7 @@ describe("Settings Screen", () => {
await toggleAdvancedSettings(user);
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
const resetButton = screen.getByText("Reset to defaults");
await user.click(resetButton);
// show modal
@@ -601,7 +640,7 @@ describe("Settings Screen", () => {
);
await user.click(confirmationModeSwitch);
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
@@ -668,6 +707,7 @@ describe("Settings Screen", () => {
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",
@@ -679,7 +719,7 @@ describe("Settings Screen", () => {
await waitFor(() => {
expect(screen.getByTestId("language-input")).toHaveValue("Norsk");
expect(screen.getByText("Disconnect Tokens")).toBeInTheDocument();
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(
@@ -714,12 +754,13 @@ describe("Settings Screen", () => {
expect(languageInput).toHaveValue("Norsk");
const saveButton = screen.getByText("BUTTON$SAVE");
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
provider_tokens: undefined,
language: "no",
}),
);
@@ -751,11 +792,12 @@ describe("Settings Screen", () => {
const gpt4Option = await screen.findByText("gpt-4o");
await user.click(gpt4Option);
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: undefined,
llm_api_key: "", // empty because it's not set previously
llm_model: "openai/gpt-4o",
}),
@@ -776,7 +818,7 @@ describe("Settings Screen", () => {
expect(languageInput).toHaveValue("Norsk");
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
const resetButton = screen.getByText("Reset to defaults");
await user.click(resetButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
@@ -804,17 +846,11 @@ describe("Settings Screen", () => {
// Wait for the mutation to complete and the modal to be removed
await waitFor(() => {
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
expect(
screen.queryByTestId("llm-custom-model-input"),
).not.toBeInTheDocument();
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();
expect(screen.queryByTestId("security-analyzer-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("enable-confirmation-mode-switch")).not.toBeInTheDocument();
});
});
@@ -824,7 +860,7 @@ describe("Settings Screen", () => {
renderSettingsScreen();
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
const resetButton = await screen.findByText("Reset to defaults");
await user.click(resetButton);
const modal = await screen.findByTestId("reset-modal");
@@ -853,7 +889,7 @@ describe("Settings Screen", () => {
await user.click(analyticsConsentInput);
expect(analyticsConsentInput).toBeChecked();
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
@@ -867,7 +903,7 @@ describe("Settings Screen", () => {
);
renderSettingsScreen();
const saveButton = await screen.findByText("BUTTON$SAVE");
const saveButton = await screen.findByText("Save Changes");
await user.click(saveButton);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
@@ -900,7 +936,7 @@ describe("Settings Screen", () => {
const user = userEvent.setup();
renderSettingsScreen();
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
@@ -917,7 +953,7 @@ describe("Settings Screen", () => {
const input = await screen.findByTestId("llm-api-key-input");
expect(input).toHaveValue("");
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
@@ -929,7 +965,7 @@ describe("Settings Screen", () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key_set: true,
llm_api_key: "**********",
});
renderSettingsScreen();
@@ -937,7 +973,7 @@ describe("Settings Screen", () => {
const input = await screen.findByTestId("llm-api-key-input");
expect(input).toHaveValue("");
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
@@ -952,7 +988,7 @@ describe("Settings Screen", () => {
const input = await screen.findByTestId("llm-api-key-input");
await user.type(input, "new-api-key");
const saveButton = screen.getByText("BUTTON$SAVE");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
@@ -1032,7 +1068,7 @@ describe("Settings Screen", () => {
const user = userEvent.setup();
renderSettingsScreen();
const saveButton = await screen.findByText("BUTTON$SAVE");
const saveButton = await screen.findByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
@@ -1048,7 +1084,7 @@ describe("Settings Screen", () => {
const user = userEvent.setup();
renderSettingsScreen();
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
const resetButton = await screen.findByText("Reset to defaults");
await user.click(resetButton);
const modal = await screen.findByTestId("reset-modal");
@@ -2,8 +2,6 @@ import { render, screen } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { ChatInput } from "#/components/features/chat/chat-input";
import path from 'path';
import { scanDirectoryForUnlocalizedStrings } from "#/utils/scan-unlocalized-strings-ast";
// Mock react-i18next
vi.mock("react-i18next", () => ({
@@ -39,23 +37,4 @@ describe("Check for hardcoded English strings", () => {
render(<ChatInput onSubmit={() => {}} />);
screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
});
test("No unlocalized strings should exist in frontend code", () => {
const srcPath = path.resolve(__dirname, '../../src');
// Get unlocalized strings using the AST scanner
// The scanner now properly handles CSS classes using AST information
const results = scanDirectoryForUnlocalizedStrings(srcPath);
// If we found any unlocalized strings, format them for output
if (results.size > 0) {
const formattedResults = Array.from(results.entries())
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
.join('\n');
throw new Error(
`Found unlocalized strings in the following files:${formattedResults}`
);
}
});
});
});
+5 -8
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.31.0",
"version": "0.30.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.31.0",
"version": "0.30.1",
"dependencies": {
"@heroui/react": "2.7.5",
"@monaco-editor/react": "^4.7.0-rc.0",
@@ -51,9 +51,6 @@
"ws": "^8.18.1"
},
"devDependencies": {
"@babel/parser": "^7.27.0",
"@babel/traverse": "^7.27.0",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.51.1",
"@react-router/dev": "^7.4.0",
@@ -17143,9 +17140,9 @@
}
},
"node_modules/vite": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz",
"integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==",
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz",
"integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.31.0",
"version": "0.30.1",
"private": true,
"type": "module",
"engines": {
@@ -79,9 +79,6 @@
]
},
"devDependencies": {
"@babel/parser": "^7.27.0",
"@babel/traverse": "^7.27.0",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.51.1",
"@react-router/dev": "^7.4.0",
@@ -1,4 +1,3 @@
import { GitRepository } from "#/types/git";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { openHands } from "./open-hands-axios";
@@ -15,8 +14,8 @@ export const retrieveGitHubAppRepositories = async (
per_page = 30,
) => {
const installationId = installations[installationIndex];
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
const response = await openHands.get<GitHubRepository[]>(
"/api/github/repositories",
{
params: {
sort: "pushed",
@@ -54,17 +53,21 @@ export const retrieveGitHubAppRepositories = async (
* Given a PAT, retrieves the repositories of the user
* @returns A list of repositories
*/
export const retrieveUserGitRepositories = async () => {
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
export const retrieveGitHubUserRepositories = async (
page = 1,
per_page = 30,
) => {
const response = await openHands.get<GitHubRepository[]>(
"/api/github/repositories",
{
params: {
sort: "pushed",
page,
per_page,
},
},
);
// Check if any provider has more results
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header
+19
View File
@@ -3,3 +3,22 @@ import axios from "axios";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
export const setAuthTokenHeader = (token: string) => {
openHands.defaults.headers.common.Authorization = `Bearer ${token}`;
};
export const setGitHubTokenHeader = (token: string) => {
openHands.defaults.headers.common["X-GitHub-Token"] = token;
};
export const removeAuthTokenHeader = () => {
if (openHands.defaults.headers.common.Authorization) {
delete openHands.defaults.headers.common.Authorization;
}
};
export const removeGitHubTokenHeader = () => {
if (openHands.defaults.headers.common["X-GitHub-Token"]) {
delete openHands.defaults.headers.common["X-GitHub-Token"];
}
};
+13 -9
View File
@@ -14,7 +14,6 @@ import {
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { GitUser, GitRepository } from "#/types/git";
class OpenHands {
/**
@@ -224,7 +223,7 @@ class OpenHands {
}
static async createConversation(
selectedRepository?: GitRepository,
selectedRepository?: string,
initialUserMsg?: string,
imageUrls?: string[],
replayJson?: string,
@@ -306,12 +305,12 @@ class OpenHands {
return data.credits;
}
static async getGitUser(): Promise<GitUser> {
const response = await openHands.get<GitUser>("/api/user/info");
static async getGitHubUser(): Promise<GitHubUser> {
const response = await openHands.get<GitHubUser>("/api/github/user");
const { data } = response;
const user: GitUser = {
const user: GitHubUser = {
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
@@ -323,12 +322,17 @@ class OpenHands {
return user;
}
static async searchGitRepositories(
static async getGitHubUserInstallationIds(): Promise<number[]> {
const response = await openHands.get<number[]>("/api/github/installations");
return response.data;
}
static async searchGitHubRepositories(
query: string,
per_page = 5,
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
): Promise<GitHubRepository[]> {
const response = await openHands.get<GitHubRepository[]>(
"/api/github/search/repositories",
{
params: {
query,
+6
View File
@@ -0,0 +1,6 @@
import { ErrorResponse, FileUploadSuccessResponse } from "./open-hands.types";
export const isOpenHandsErrorResponse = (
data: ErrorResponse | FileUploadSuccessResponse,
): data is ErrorResponse =>
typeof data === "object" && data !== null && "error" in data;
@@ -1,4 +1,3 @@
import { useTranslation } from "react-i18next";
import {
BaseModalTitle,
BaseModalDescription,
@@ -8,7 +7,6 @@ import { ModalBody } from "#/components/shared/modals/modal-body";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
interface AnalyticsConsentFormModalProps {
onClose: () => void;
@@ -17,7 +15,6 @@ interface AnalyticsConsentFormModalProps {
export function AnalyticsConsentFormModal({
onClose,
}: AnalyticsConsentFormModalProps) {
const { t } = useTranslation();
const { mutate: saveUserSettings } = useSaveSettings();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -44,14 +41,16 @@ export function AnalyticsConsentFormModal({
className="flex flex-col gap-2"
>
<ModalBody className="border border-tertiary">
<BaseModalTitle title={t(I18nKey.ANALYTICS$TITLE)} />
<BaseModalTitle title="Your Privacy Preferences" />
<BaseModalDescription>
{t(I18nKey.ANALYTICS$DESCRIPTION)}
We use tools to understand how our application is used to improve
your experience. You can enable or disable analytics. Your
preferences will be stored and can be updated anytime.
</BaseModalDescription>
<label className="flex gap-2 items-center self-start">
<input name="analytics" type="checkbox" defaultChecked />
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)}
Send anonymous usage data
</label>
<BrandButton
@@ -60,7 +59,7 @@ export function AnalyticsConsentFormModal({
variant="primary"
className="w-full"
>
{t(I18nKey.ANALYTICS$CONFIRM_PREFERENCES)}
Confirm Preferences
</BrandButton>
</ModalBody>
</form>
@@ -1,19 +1,14 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface BrowserSnaphsotProps {
src: string;
}
export function BrowserSnapshot({ src }: BrowserSnaphsotProps) {
const { t } = useTranslation();
return (
<img
src={src}
style={{ objectFit: "contain", width: "100%", height: "auto" }}
className="rounded-xl"
alt={t(I18nKey.BROWSER$SCREENSHOT_ALT)}
alt="Browser Screenshot"
/>
);
}
@@ -1,11 +1,9 @@
import posthog from "posthog-js";
import React from "react";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import type { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
import { I18nKey } from "#/i18n/declaration";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
@@ -14,44 +12,24 @@ interface ActionSuggestionsProps {
export function ActionSuggestions({
onSuggestionsClick,
}: ActionSuggestionsProps) {
const { t } = useTranslation();
const { providersAreSet } = useAuth();
const { githubTokenIsSet } = useAuth();
const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery,
);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const isGitLab =
selectedRepository !== null &&
selectedRepository.git_provider &&
selectedRepository.git_provider.toLowerCase() === "gitlab";
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";
const terms = {
pr,
prShort,
pushToBranch: `Please push the changes to a remote branch on ${
isGitLab ? "GitLab" : "GitHub"
}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
createPR: `Please push the changes to ${
isGitLab ? "GitLab" : "GitHub"
} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
pushToPR: `Please push the latest changes to the existing ${pr}.`,
};
return (
<div className="flex flex-col gap-2 mb-2">
{providersAreSet && selectedRepository && (
{githubTokenIsSet && selectedRepository && (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
<SuggestionItem
suggestion={{
label: t(I18nKey.ACTION$PUSH_TO_BRANCH),
value: terms.pushToBranch,
label: "Push to Branch",
value:
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.",
}}
onClick={(value) => {
posthog.capture("push_to_branch_button_clicked");
@@ -60,8 +38,9 @@ export function ActionSuggestions({
/>
<SuggestionItem
suggestion={{
label: t(I18nKey.ACTION$PUSH_CREATE_PR),
value: terms.createPR,
label: "Push & Create PR",
value:
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.",
}}
onClick={(value) => {
posthog.capture("create_pr_button_clicked");
@@ -73,8 +52,9 @@ export function ActionSuggestions({
) : (
<SuggestionItem
suggestion={{
label: t(I18nKey.ACTION$PUSH_CHANGES_TO_PR),
value: terms.pushToPR,
label: "Push changes to PR",
value:
"Please push the latest changes to the existing pull request.",
}}
onClick={(value) => {
posthog.capture("push_to_pr_button_clicked");
@@ -2,8 +2,6 @@ import { useDispatch, useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { createChatMessage } from "#/services/chat-service";
@@ -38,7 +36,6 @@ function getEntryPoint(
export function ChatInterface() {
const { send, isLoadingMessages } = useWsClient();
const dispatch = useDispatch();
const { t } = useTranslation();
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);
@@ -97,19 +94,19 @@ export function ChatInterface() {
const onClickExportTrajectoryButton = () => {
if (!params.conversationId) {
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
displayErrorToast("ConversationId unknown, cannot download trajectory");
return;
}
getTrajectory(params.conversationId, {
onSuccess: async (data) => {
await downloadTrajectory(
params.conversationId ?? t(I18nKey.CONVERSATION$UNKNOWN),
params.conversationId ?? "unknown",
data.trajectory,
);
},
onError: () => {
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
onError: (error) => {
displayErrorToast(error.message);
},
});
};
@@ -127,7 +124,7 @@ export function ChatInterface() {
<div
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{isLoadingMessages && (
<div className="flex justify-center">
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Link } from "react-router";
import { I18nKey } from "#/i18n/declaration";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
import ArrowUp from "#/icons/angle-up-solid.svg?react";
@@ -45,7 +44,7 @@ export function ExpandableMessage({
if (
config?.FEATURE_FLAGS.ENABLE_BILLING &&
config?.APP_MODE === "saas" &&
id === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS
id === "STATUS$ERROR_LLM_OUT_OF_CREDITS"
) {
return (
<div
@@ -54,13 +53,13 @@ export function ExpandableMessage({
>
<div className="text-sm w-full">
<div className="font-bold text-danger">
{t(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS)}
{t("STATUS$ERROR_LLM_OUT_OF_CREDITS")}
</div>
<Link
className="mt-2 mb-2 w-full h-10 rounded flex items-center justify-center gap-2 bg-primary text-[#0D0F11]"
to="/settings/billing"
>
{t(I18nKey.BILLING$CLICK_TO_TOP_UP)}
{t("BILLING$CLICK_TO_TOP_UP")}
</Link>
</div>
</div>
@@ -1,7 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import { showErrorToast } from "#/utils/error-handler";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
@@ -79,7 +78,7 @@ export function AgentStatusBar() {
React.useEffect(() => {
if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage(t(I18nKey.STATUS$CONNECTED)); // Using STATUS$CONNECTED instead of STATUS$CONNECTING
setStatusMessage("Connecting...");
setIndicatorColor(IndicatorColor.RED);
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
@@ -1,4 +1,3 @@
import { useTranslation } from "react-i18next";
import {
BaseModalDescription,
BaseModalTitle,
@@ -6,7 +5,6 @@ import {
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
interface ConfirmDeleteModalProps {
onConfirm: () => void;
@@ -17,16 +15,12 @@ export function ConfirmDeleteModal({
onConfirm,
onCancel,
}: ConfirmDeleteModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<ModalBody className="items-start border border-tertiary">
<div className="flex flex-col gap-2">
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_DELETE)} />
<BaseModalDescription
description={t(I18nKey.CONVERSATION$DELETE_WARNING)}
/>
<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"
@@ -37,18 +31,16 @@ export function ConfirmDeleteModal({
variant="primary"
onClick={onConfirm}
className="w-full"
data-testid="confirm-button"
>
{t(I18nKey.ACTION$CONFIRM)}
Confirm
</BrandButton>
<BrandButton
type="button"
variant="secondary"
onClick={onCancel}
className="w-full"
data-testid="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
Cancel
</BrandButton>
</div>
</ModalBody>
@@ -1,7 +1,6 @@
import React from "react";
import { useSelector } from "react-redux";
import posthog from "posthog-js";
import { useTranslation } from "react-i18next";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationRepoLink } from "./conversation-repo-link";
import {
@@ -13,7 +12,6 @@ import { ConversationCardContextMenu } from "./conversation-card-context-menu";
import { cn } from "#/utils/utils";
import { BaseModal } from "../../shared/modals/base-modal/base-modal";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
interface ConversationCardProps {
onClick?: () => void;
@@ -48,7 +46,6 @@ export function ConversationCard({
variant = "default",
conversationId,
}: ConversationCardProps) {
const { t } = useTranslation();
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
@@ -223,18 +220,14 @@ export function ConversationCard({
<ConversationRepoLink selectedRepository={selectedRepository} />
)}
<p className="text-xs text-neutral-400">
<span>{t(I18nKey.CONVERSATION$CREATED)} </span>
<span>Created </span>
<time>
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))}{" "}
{t(I18nKey.CONVERSATION$AGO)}
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))} ago
</time>
{showUpdateTime && (
<>
<span>{t(I18nKey.CONVERSATION$UPDATED)} </span>
<time>
{formatTimeDelta(new Date(lastUpdatedAt))}{" "}
{t(I18nKey.CONVERSATION$AGO)}
</time>
<span>, updated </span>
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
</>
)}
</p>
@@ -244,7 +237,7 @@ export function ConversationCard({
<BaseModal
isOpen={metricsModalVisible}
onOpenChange={setMetricsModalVisible}
title={t(I18nKey.CONVERSATION$METRICS_INFO)}
title="Metrics Information"
testID="metrics-modal"
>
<div className="space-y-4">
@@ -254,7 +247,7 @@ export function ConversationCard({
{metrics?.cost !== null && (
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
<span className="text-lg font-semibold">
{t(I18nKey.CONVERSATION$TOTAL_COST)}
Total Cost (USD):
</span>
<span className="font-semibold">
${metrics.cost.toFixed(4)}
@@ -265,7 +258,7 @@ export function ConversationCard({
{metrics?.usage !== null && (
<>
<div className="flex justify-between items-center pb-2">
<span>{t(I18nKey.CONVERSATION$INPUT)}:</span>
<span>Total Input Tokens:</span>
<span className="font-semibold">
{metrics.usage.prompt_tokens.toLocaleString()}
</span>
@@ -283,16 +276,14 @@ export function ConversationCard({
</div>
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
<span>{t(I18nKey.CONVERSATION$OUTPUT)}:</span>
<span>Total Output Tokens:</span>
<span className="font-semibold">
{metrics.usage.completion_tokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center pt-1">
<span className="font-semibold">
{t(I18nKey.CONVERSATION$TOTAL)}:
</span>
<span className="font-semibold">Total Tokens:</span>
<span className="font-bold">
{(
metrics.usage.prompt_tokens +
@@ -308,9 +299,7 @@ export function ConversationCard({
{!metrics?.cost && !metrics?.usage && (
<div className="rounded-md p-4 text-center">
<p className="text-neutral-400">
{t(I18nKey.CONVERSATION$NO_METRICS)}
</p>
<p className="text-neutral-400">No metrics data available</p>
</div>
)}
</div>
@@ -1,9 +1,7 @@
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
import { I18nKey } from "#/i18n/declaration";
interface ExitConversationModalProps {
onConfirm: () => void;
@@ -14,20 +12,18 @@ export function ExitConversationModal({
onConfirm,
onClose,
}: ExitConversationModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<ModalBody testID="confirm-new-conversation-modal">
<BaseModalTitle title={t(I18nKey.CONVERSATION$EXIT_WARNING)} />
<BaseModalTitle title="Creating a new conversation will replace your active conversation" />
<div className="flex w-full gap-2">
<ModalButton
text={t(I18nKey.ACTION$CONFIRM)}
text="Confirm"
onClick={onConfirm}
className="bg-[#C63143] flex-1"
/>
<ModalButton
text={t(I18nKey.BUTTON$CANCEL)}
text="Cancel"
onClick={onClose}
className="bg-tertiary flex-1"
/>
@@ -1,5 +1,3 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import {
BaseModalTitle,
BaseModalDescription,
@@ -19,14 +17,13 @@ export function FeedbackModal({
isOpen,
polarity,
}: FeedbackModalProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<ModalBackdrop onClose={onClose}>
<ModalBody className="border border-tertiary">
<BaseModalTitle title={t(I18nKey.FEEDBACK$TITLE)} />
<BaseModalDescription description={t(I18nKey.FEEDBACK$DESCRIPTION)} />
<BaseModalTitle title="Feedback" />
<BaseModalDescription description="To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." />
<FeedbackForm onClose={onClose} polarity={polarity} />
</ModalBody>
</ModalBackdrop>
@@ -1,203 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
Spinner,
} from "@heroui/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { GitRepository } from "#/types/git";
import { Provider, ProviderOptions } from "#/types/settings";
interface GitRepositorySelectorProps {
onInputChange: (value: string) => void;
onSelect: () => void;
userRepositories: GitRepository[];
publicRepositories: GitRepository[];
isLoading?: boolean;
}
export function GitRepositorySelector({
onInputChange,
onSelect,
userRepositories,
publicRepositories,
isLoading = false,
}: GitRepositorySelectorProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const allRepositories: GitRepository[] = [
...publicRepositories.filter(
(repo) => !userRepositories.find((r) => r.id === repo.id),
),
...userRepositories,
];
// Group repositories by provider
const groupedUserRepos = userRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const groupedPublicRepos = publicRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const dispatch = useDispatch();
const handleRepoSelection = (id: string | null) => {
const repo = allRepositories.find((r) => r.id.toString() === id);
if (repo) {
dispatch(setSelectedRepository(repo));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
}
};
const handleClearSelection = () => {
dispatch(setSelectedRepository(null));
};
const emptyContent = isLoading ? (
<div className="flex items-center justify-center py-2">
<Spinner size="sm" className="mr-2" />
<span>{t(I18nKey.GITHUB$LOADING_REPOSITORIES)}</span>
</div>
) : (
t(I18nKey.GITHUB$NO_RESULTS)
);
return (
<Autocomplete
data-testid="github-repo-selector"
name="repo"
aria-label="Git Repository"
placeholder={t(I18nKey.LANDING$SELECT_GIT_REPO)}
isVirtualized={false}
selectedKey={selectedKey}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
endContent: isLoading ? <Spinner size="sm" /> : undefined,
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
const sanitizedInput = sanitizeQuery(inputValue);
const repo = allRepositories.find((r) => r.full_name === textValue);
if (!repo) return false;
const provider = repo.git_provider?.toLowerCase() as Provider;
const providerKeys = Object.keys(ProviderOptions) as Provider[];
// If input is exactly "git", show repos from any git-based provider
if (sanitizedInput === "git") {
return providerKeys.includes(provider);
}
// Provider based typeahead
for (const p of providerKeys) {
if (p.startsWith(sanitizedInput)) {
return provider === p;
}
}
// Default case: check if the repository name matches the input
return sanitizeQuery(textValue).includes(sanitizedInput);
}}
>
{config?.APP_MODE === "saas" &&
config?.APP_SLUG &&
((
<AutocompleteItem key="install">
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
>
{t(I18nKey.GITHUB$ADD_MORE_REPOS)}
</a>
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any)}
{Object.entries(groupedUserRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`user-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$YOUR_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
{Object.entries(groupedPublicRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`public-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$PUBLIC_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
</Autocomplete>
);
}
@@ -1,15 +1,12 @@
import React from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { setInitialPrompt } from "#/state/initial-query-slice";
const INITIAL_PROMPT = "";
export function CodeNotInGitLink() {
export function CodeNotInGitHubLink() {
const dispatch = useDispatch();
const { t } = useTranslation();
const { mutate: createConversation } = useCreateConversation();
const handleStartFromScratch = () => {
@@ -20,14 +17,14 @@ export function CodeNotInGitLink() {
return (
<div className="text-xs text-neutral-400">
{t(I18nKey.GITHUB$CODE_NOT_IN_GITHUB)}{" "}
Code not in GitHub?{" "}
<span
onClick={handleStartFromScratch}
className="underline cursor-pointer"
>
{t(I18nKey.GITHUB$START_FROM_SCRATCH)}
Start from scratch
</span>{" "}
{t(I18nKey.GITHUB$VSCODE_LINK_DESCRIPTION)}
and use the VS Code link to upload and download your code.
</div>
);
}
@@ -0,0 +1,129 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
} from "@heroui/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";
import { sanitizeQuery } from "#/utils/sanitize-query";
interface GitHubRepositorySelectorProps {
onInputChange: (value: string) => void;
onSelect: () => void;
userRepositories: GitHubRepository[];
publicRepositories: GitHubRepository[];
}
export function GitHubRepositorySelector({
onInputChange,
onSelect,
userRepositories,
publicRepositories,
}: GitHubRepositorySelectorProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const allRepositories: GitHubRepository[] = [
...publicRepositories.filter(
(repo) => !userRepositories.find((r) => r.id === repo.id),
),
...userRepositories,
];
const dispatch = useDispatch();
const handleRepoSelection = (id: string | null) => {
const repo = allRepositories.find((r) => r.id.toString() === id);
if (repo) {
dispatch(setSelectedRepository(repo.full_name));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
}
};
const handleClearSelection = () => {
dispatch(setSelectedRepository(null));
};
const emptyContent = t(I18nKey.GITHUB$NO_RESULTS);
return (
<Autocomplete
data-testid="github-repo-selector"
name="repo"
aria-label="GitHub Repository"
placeholder={t(I18nKey.LANDING$SELECT_REPO)}
isVirtualized={false}
selectedKey={selectedKey}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
defaultFilter={(textValue, inputValue) =>
!inputValue ||
sanitizeQuery(textValue).includes(sanitizeQuery(inputValue))
}
>
{config?.APP_MODE === "saas" &&
config?.APP_SLUG &&
((
<AutocompleteItem key="install">
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
>
{t(I18nKey.GITHUB$ADD_MORE_REPOS)}
</a>
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any)}
{userRepositories.length > 0 && (
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$YOUR_REPOS)}>
{userRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
)}
{publicRepositories.length > 0 && (
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$PUBLIC_REPOS)}>
{publicRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
)}
</Autocomplete>
);
}
@@ -3,42 +3,42 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { I18nKey } from "#/i18n/declaration";
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
import { GitRepositorySelector } from "./git-repo-selector";
import { GitHubRepositorySelector } from "./github-repo-selector";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useDebounce } from "#/hooks/use-debounce";
import { BrandButton } from "../settings/brand-button";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { GitHubErrorReponse, GitUser } from "#/types/git";
interface GitRepositoriesSuggestionBoxProps {
interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
gitHubAuthUrl: string | null;
user: GitHubErrorReponse | GitUser | null;
user: GitHubErrorReponse | GitHubUser | null;
}
export function GitRepositoriesSuggestionBox({
export function GitHubRepositoriesSuggestionBox({
handleSubmit,
gitHubAuthUrl,
user,
}: GitRepositoriesSuggestionBoxProps) {
}: GitHubRepositoriesSuggestionBoxProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = React.useState<string>("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// TODO: Use `useQueries` to fetch all repositories in parallel
const { data: userRepositories, isLoading: isUserReposLoading } =
useUserRepositories();
const { data: searchedRepos, isLoading: isSearchReposLoading } =
useSearchRepositories(sanitizeQuery(debouncedSearchQuery));
const isLoading = isUserReposLoading || isSearchReposLoading;
const { data: appRepositories } = useAppRepositories();
const { data: userRepositories } = useUserRepositories();
const { data: searchedRepos } = useSearchRepositories(
sanitizeQuery(debouncedSearchQuery),
);
const repositories =
userRepositories?.pages.flatMap((page) => page.data) || [];
userRepositories?.pages.flatMap((page) => page.data) ||
appRepositories?.pages.flatMap((page) => page.data) ||
[];
const handleConnectToGitHub = () => {
if (gitHubAuthUrl) {
@@ -55,12 +55,11 @@ export function GitRepositoriesSuggestionBox({
title={t(I18nKey.LANDING$OPEN_REPO)}
content={
isLoggedIn ? (
<GitRepositorySelector
<GitHubRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
publicRepositories={searchedRepos || []}
userRepositories={repositories}
isLoading={isLoading}
/>
) : (
<BrandButton
@@ -20,7 +20,7 @@ export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
<div
data-testid="jupyter-container"
className="flex-1 overflow-y-auto fast-smooth-scroll"
className="flex-1 overflow-y-auto"
ref={jupyterRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
>
@@ -1,5 +1,4 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
import { useBalance } from "#/hooks/query/use-balance";
import { cn } from "#/utils/utils";
@@ -8,10 +7,8 @@ import { SettingsInput } from "../settings/settings-input";
import { BrandButton } from "../settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { amountIsValid } from "#/utils/amount-is-valid";
import { I18nKey } from "#/i18n/declaration";
export function PaymentForm() {
const { t } = useTranslation();
const { data: balance, isLoading } = useBalance();
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
@@ -41,7 +38,7 @@ export function PaymentForm() {
className="flex flex-col gap-6 px-11 py-9"
>
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.PAYMENT$MANAGE_CREDITS)}
Manage Credits
</h2>
<div
@@ -66,7 +63,7 @@ export function PaymentForm() {
name="top-up-input"
onChange={handleTopUpInputChange}
type="text"
label={t(I18nKey.PAYMENT$ADD_FUNDS)}
label="Add funds"
placeholder="Specify an amount in USD to add - min $10"
className="w-[680px]"
/>
@@ -77,7 +74,7 @@ export function PaymentForm() {
type="submit"
isDisabled={isPending || buttonIsDisabled}
>
{t(I18nKey.PAYMENT$ADD_CREDIT)}
Add credit
</BrandButton>
{isPending && <LoadingSpinner size="small" />}
</div>
@@ -1,6 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import { Trans, useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
@@ -16,7 +15,7 @@ export function SetupPaymentModal() {
window.location.href = data;
},
onError: () => {
displayErrorToast(t(I18nKey.BILLING$ERROR_WHILE_CREATING_SESSION));
displayErrorToast(t("BILLING$ERROR_WHILE_CREATING_SESSION"));
},
});
@@ -25,9 +24,7 @@ export function SetupPaymentModal() {
<ModalBody className="border border-tertiary">
<AllHandsLogo width={68} height={46} />
<div className="flex flex-col gap-2 w-full items-center text-center">
<h1 className="text-2xl font-bold">
{t(I18nKey.BILLING$YOUVE_GOT_50)}
</h1>
<h1 className="text-2xl font-bold">{t("BILLING$YOUVE_GOT_50")}</h1>
<p>
<Trans
i18nKey="BILLING$CLAIM_YOUR_50"
@@ -43,7 +40,7 @@ export function SetupPaymentModal() {
isDisabled={isPending}
onClick={mutate}
>
{t(I18nKey.BILLING$PROCEED_TO_STRIPE)}
{t("BILLING$PROCEED_TO_STRIPE")}
</BrandButton>
</ModalBody>
</ModalBackdrop>
@@ -1,17 +1,9 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface AvatarProps {
src: string;
}
export function Avatar({ src }: AvatarProps) {
const { t } = useTranslation();
return (
<img
src={src}
alt={t(I18nKey.AVATAR$ALT_TEXT)}
className="w-full h-full rounded-full"
/>
<img src={src} alt="user avatar" className="w-full h-full rounded-full" />
);
}
@@ -3,8 +3,7 @@ import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { NavLink, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { useGitUser } from "#/hooks/query/use-git-user";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
import { DocsButton } from "#/components/shared/buttons/docs-button";
@@ -22,14 +21,12 @@ import { useLogout } from "#/hooks/mutation/use-logout";
import { useConfig } from "#/hooks/query/use-config";
import { cn } from "#/utils/utils";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
export function Sidebar() {
const { t } = useTranslation();
const location = useLocation();
const dispatch = useDispatch();
const endSession = useEndSession();
const user = useGitUser();
const user = useGitHubUser();
const { data: config } = useConfig();
const {
data: settings,
@@ -94,8 +91,8 @@ export function Sidebar() {
<ExitProjectButton onClick={handleEndSession} />
<TooltipButton
testId="toggle-conversation-panel"
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
tooltip="Conversations"
ariaLabel="Conversations"
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
>
<FaListUl
@@ -1,5 +1,4 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
import ExportIcon from "#/icons/export.svg?react";
@@ -24,19 +23,19 @@ export function TrajectoryActions({
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
tooltip={t("BUTTON$MARK_HELPFUL")}
/>
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
tooltip={t("BUTTON$MARK_NOT_HELPFUL")}
/>
<TrajectoryActionButton
testId="export-trajectory"
onClick={onExportTrajectory}
icon={<ExportIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$EXPORT_CONVERSATION)}
tooltip={t("BUTTON$EXPORT_CONVERSATION")}
/>
</div>
);
@@ -1,9 +1,4 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function JoinWaitlistAnchor() {
const { t } = useTranslation();
return (
<a
href="https://www.all-hands.dev/join-waitlist"
@@ -11,7 +6,7 @@ export function JoinWaitlistAnchor() {
rel="noreferrer"
className="rounded bg-[#FFE165] text-black text-sm font-bold py-[10px] w-full text-center hover:opacity-80"
>
{t(I18nKey.WAITLIST$JOIN_WAITLIST)}
Join Waitlist
</a>
);
}
@@ -1,35 +1,34 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface WaitlistMessageProps {
content: "waitlist" | "sign-in";
}
export function WaitlistMessage({ content }: WaitlistMessageProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 w-full items-center text-center">
<h1 className="text-2xl font-bold">
{content === "sign-in" && t(I18nKey.AUTH$SIGN_IN_WITH_GITHUB)}
{content === "waitlist" && t(I18nKey.WAITLIST$ALMOST_THERE)}
{content === "sign-in" && "Sign in with GitHub"}
{content === "waitlist" && "Just a little longer!"}
</h1>
{content === "sign-in" && (
<p>
{t(I18nKey.LANDING$OR)}{" "}
or{" "}
<a
href="https://www.all-hands.dev/join-waitlist"
target="_blank"
rel="noreferrer noopener"
className="text-blue-500 hover:underline underline-offset-2"
>
{t(I18nKey.WAITLIST$JOIN)}
join the waitlist
</a>{" "}
{t(I18nKey.WAITLIST$IF_NOT_JOINED)}
if you haven&apos;t already
</p>
)}
{content === "waitlist" && (
<p className="text-sm">{t(I18nKey.WAITLIST$PATIENCE_MESSAGE)}</p>
<p className="text-sm">
Thanks for your patience! We&apos;re accepting new members
progressively. If you haven&apos;t joined the waitlist yet, now&apos;s
the time!
</p>
)}
</div>
);
@@ -1,6 +1,4 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { JoinWaitlistAnchor } from "./join-waitlist-anchor";
import { WaitlistMessage } from "./waitlist-message";
@@ -20,7 +18,6 @@ export function WaitlistModal({
ghTokenIsSet,
githubAuthUrl,
}: WaitlistModalProps) {
const { t } = useTranslation();
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
const handleGitHubAuth = () => {
@@ -47,7 +44,7 @@ export function WaitlistModal({
className="w-full"
startContent={<GitHubLogo width={20} height={20} />}
>
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
Connect to GitHub
</BrandButton>
)}
{ghTokenIsSet && <JoinWaitlistAnchor />}
@@ -1,5 +1,3 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { TooltipButton } from "./tooltip-button";
@@ -8,11 +6,10 @@ interface AllHandsLogoButtonProps {
}
export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
const { t } = useTranslation();
return (
<TooltipButton
tooltip={t(I18nKey.BRANDING$ALL_HANDS_AI)}
ariaLabel={t(I18nKey.BRANDING$ALL_HANDS_LOGO)}
tooltip="All Hands AI"
ariaLabel="All Hands Logo"
onClick={onClick}
>
<AllHandsLogo width={34} height={34} />
@@ -1,7 +1,5 @@
import { useTranslation } from "react-i18next";
import CheckmarkIcon from "#/icons/checkmark.svg?react";
import CopyIcon from "#/icons/copy.svg?react";
import { I18nKey } from "#/i18n/declaration";
interface CopyToClipboardButtonProps {
isHidden: boolean;
@@ -16,7 +14,6 @@ export function CopyToClipboardButton({
onClick,
mode,
}: CopyToClipboardButtonProps) {
const { t } = useTranslation();
return (
<button
hidden={isHidden}
@@ -24,9 +21,6 @@ export function CopyToClipboardButton({
data-testid="copy-to-clipboard"
type="button"
onClick={onClick}
aria-label={t(
mode === "copy" ? I18nKey.BUTTON$COPY : I18nKey.BUTTON$COPIED,
)}
className="button-base p-1 absolute top-1 right-1"
>
{mode === "copy" && <CopyIcon width={15} height={15} />}
@@ -1,15 +1,11 @@
import { IoIosRefresh } from "react-icons/io";
import { useTranslation } from "react-i18next";
import { IconButton } from "./icon-button";
import { I18nKey } from "#/i18n/declaration";
interface RefreshIconButtonProps {
onClick: () => void;
}
export function RefreshIconButton({ onClick }: RefreshIconButtonProps) {
const { t } = useTranslation();
return (
<IconButton
icon={
@@ -19,7 +15,7 @@ export function RefreshIconButton({ onClick }: RefreshIconButtonProps) {
/>
}
testId="refresh"
ariaLabel={t(I18nKey.WORKSPACE$REFRESH)}
ariaLabel="Refresh workspace"
onClick={onClick}
/>
);
@@ -1,7 +1,5 @@
import { IoIosArrowForward, IoIosArrowBack } from "react-icons/io";
import { useTranslation } from "react-i18next";
import { IconButton } from "./icon-button";
import { I18nKey } from "#/i18n/declaration";
interface ToggleWorkspaceIconButtonProps {
onClick: () => void;
@@ -12,8 +10,6 @@ export function ToggleWorkspaceIconButton({
onClick,
isHidden,
}: ToggleWorkspaceIconButtonProps) {
const { t } = useTranslation();
return (
<IconButton
icon={
@@ -30,9 +26,7 @@ export function ToggleWorkspaceIconButton({
)
}
testId="toggle"
ariaLabel={
isHidden ? t(I18nKey.WORKSPACE$OPEN) : t(I18nKey.WORKSPACE$CLOSE)
}
ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
onClick={onClick}
/>
);
@@ -20,7 +20,7 @@ export function BaseUrlInput({ isDisabled, defaultValue }: BaseUrlInputProps) {
id="base-url"
name="base-url"
defaultValue={defaultValue}
aria-label={t(I18nKey.SETTINGS_FORM$BASE_URL)}
aria-label="Base URL"
classNames={{
inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
}}
@@ -1,11 +1,9 @@
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useEndSession } from "#/hooks/use-end-session";
import { setCurrentAgentState } from "#/state/agent-slice";
import { AgentState } from "#/types/agent-state";
import { DangerModal } from "./confirmation-modals/danger-modal";
import { ModalBackdrop } from "./modal-backdrop";
import { I18nKey } from "#/i18n/declaration";
interface ExitProjectConfirmationModalProps {
onClose: () => void;
@@ -14,7 +12,6 @@ interface ExitProjectConfirmationModalProps {
export function ExitProjectConfirmationModal({
onClose,
}: ExitProjectConfirmationModalProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const endSession = useEndSession();
@@ -27,15 +24,15 @@ export function ExitProjectConfirmationModal({
return (
<ModalBackdrop onClose={onClose}>
<DangerModal
title={t(I18nKey.EXIT_PROJECT$CONFIRM)}
description={t(I18nKey.EXIT_PROJECT$WARNING)}
title="Are you sure you want to exit?"
description="You will lose any unsaved information."
buttons={{
danger: {
text: t(I18nKey.EXIT_PROJECT$TITLE),
text: "Exit Project",
onClick: handleEndSession,
},
cancel: {
text: t(I18nKey.BUTTON$CANCEL),
text: "Cancel",
onClick: onClose,
},
}}
@@ -131,10 +131,7 @@ function SecurityInvariant() {
{t(I18nKey.INVARIANT$EXPORT_TRACE_LABEL)}
</Button>
</div>
<div
className="flex-1 p-4 max-h-screen overflow-y-auto fast-smooth-scroll"
ref={logsRef}
>
<div className="flex-1 p-4 max-h-screen overflow-y-auto" ref={logsRef}>
{logs.map((log: SecurityAnalyzerLog, index: number) => (
<div
key={index}
@@ -93,7 +93,7 @@ export function ModelSelector({
},
}}
>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
<AutocompleteSection title="Verified">
{Object.keys(models)
.filter((provider) => VERIFIED_PROVIDERS.includes(provider))
.map((provider) => (
@@ -105,7 +105,7 @@ export function ModelSelector({
</AutocompleteItem>
))}
</AutocompleteSection>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
<AutocompleteSection title="Others">
{Object.keys(models)
.filter((provider) => !VERIFIED_PROVIDERS.includes(provider))
.map((provider) => (
@@ -143,14 +143,14 @@ export function ModelSelector({
},
}}
>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
<AutocompleteSection title="Verified">
{models[selectedProvider || ""]?.models
.filter((model) => VERIFIED_MODELS.includes(model))
.map((model) => (
<AutocompleteItem key={model}>{model}</AutocompleteItem>
))}
</AutocompleteSection>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
<AutocompleteSection title="Others">
{models[selectedProvider || ""]?.models
.filter((model) => !VERIFIED_MODELS.includes(model))
.map((model) => (
@@ -50,7 +50,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.LLM_MODEL,
LLM_API_KEY_SET: newSettings.LLM_API_KEY_SET ? "SET" : "UNSET",
LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
});
@@ -74,7 +74,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
}
};
const isLLMKeySet = settings.LLM_API_KEY_SET;
const isLLMKeySet = settings.LLM_API_KEY === "**********";
return (
<div>
@@ -93,7 +93,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
label="API Key"
type="password"
className="w-[680px]"
placeholder={isLLMKeySet ? "<hidden>" : ""}
@@ -102,8 +102,8 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
text="Don't know your API key?"
linkText="Click here for instructions"
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
</div>
@@ -30,14 +30,13 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
{t(I18nKey.AI_SETTINGS$TITLE)}
</span>
<p className="text-xs text-[#A3A3A3]">
{t(I18nKey.SETTINGS$DESCRIPTION)}{" "}
{t(I18nKey.SETTINGS$FOR_OTHER_OPTIONS)}
{t(I18nKey.SETTINGS$DESCRIPTION)} For other options,{" "}
<Link
data-testid="advanced-settings-link"
to="/settings"
className="underline underline-offset-2 text-white"
>
{t(I18nKey.SETTINGS$SEE_ADVANCED_SETTINGS)}
see advanced settings
</Link>
</p>
{aiConfigOptions.isLoading && (
+9 -19
View File
@@ -1,37 +1,27 @@
import React from "react";
import { Provider } from "#/types/settings";
interface AuthContextType {
providerTokensSet: Provider[];
setProviderTokensSet: (tokens: Provider[]) => void;
providersAreSet: boolean;
setProvidersAreSet: (status: boolean) => void;
githubTokenIsSet: boolean;
setGitHubTokenIsSet: (value: boolean) => void;
}
interface AuthContextProps extends React.PropsWithChildren {
initialProviderTokens?: Provider[];
initialGithubTokenIsSet?: boolean;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({
children,
initialProviderTokens = [],
}: AuthContextProps) {
const [providerTokensSet, setProviderTokensSet] = React.useState<Provider[]>(
initialProviderTokens,
function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) {
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(
!!initialGithubTokenIsSet,
);
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(false);
const value = React.useMemo(
() => ({
providerTokensSet,
setProviderTokensSet,
providersAreSet,
setProvidersAreSet,
githubTokenIsSet,
setGitHubTokenIsSet,
}),
[providerTokensSet],
[githubTokenIsSet, setGitHubTokenIsSet],
);
return <AuthContext value={value}>{children}</AuthContext>;
@@ -24,11 +24,7 @@ export function ConversationProvider({
const value = useMemo(() => ({ conversationId }), [conversationId]);
return (
<ConversationContext.Provider value={value}>
{children}
</ConversationContext.Provider>
);
return <ConversationContext value={value}>{children}</ConversationContext>;
}
export function useConversation() {
@@ -9,7 +9,6 @@ import {
AssistantMessageAction,
UserMessageAction,
} from "#/types/core/actions";
import { useAuth } from "./auth-context";
const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
typeof event === "object" &&
@@ -111,7 +110,6 @@ export function WsClientProvider({
);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
const { providerTokensSet } = useAuth();
const messageRateHandler = useRate({ threshold: 250 });
@@ -170,7 +168,6 @@ export function WsClientProvider({
const query = {
latest_event_id: lastEvent?.id ?? -1,
conversation_id: conversationId,
providers_set: providerTokensSet,
};
const baseUrl =
@@ -17,6 +17,15 @@ export const useCreateConversation = () => {
return useMutation({
mutationFn: async (variables: { q?: string }) => {
if (
!variables.q?.trim() &&
!selectedRepository &&
files.length === 0 &&
!replayJson
) {
throw new Error("No query provided");
}
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
+2 -3
View File
@@ -4,7 +4,7 @@ import { useAuth } from "#/context/auth-context";
import { useConfig } from "../query/use-config";
export const useLogout = () => {
const { setProviderTokensSet, setProvidersAreSet } = useAuth();
const { setGitHubTokenIsSet } = useAuth();
const queryClient = useQueryClient();
const { data: config } = useConfig();
@@ -20,8 +20,7 @@ export const useLogout = () => {
queryClient.removeQueries({ queryKey: ["settings"] });
// Update token state - this will trigger a settings refetch since it's part of the query key
setProviderTokensSet([]);
setProvidersAreSet(false);
setGitHubTokenIsSet(false);
},
});
};

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