mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
24 Commits
0.35.0
...
add-remote
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e136651e46 | ||
|
|
760a14482e | ||
|
|
b5338c69d6 | ||
|
|
c99f031cdb | ||
|
|
bcc28a12fe | ||
|
|
c82b3378a6 | ||
|
|
a6d3db3ce7 | ||
|
|
4cbbfd799c | ||
|
|
0b728c0c79 | ||
|
|
e35c8ee173 | ||
|
|
9a9b143620 | ||
|
|
38578bd5f5 | ||
|
|
7b2c88ae6b | ||
|
|
0cbf3987f8 | ||
|
|
d18edc8b30 | ||
|
|
42eb355a68 | ||
|
|
5fa01ed278 | ||
|
|
1f747232cf | ||
|
|
4b1ed30e97 | ||
|
|
998de564cd | ||
|
|
06ce12eff4 | ||
|
|
88fc26d9b0 | ||
|
|
99233ec153 | ||
|
|
ae9573a503 |
2
.github/workflows/openhands-resolver.yml
vendored
2
.github/workflows/openhands-resolver.yml
vendored
@@ -24,7 +24,7 @@ on:
|
||||
LLM_MODEL:
|
||||
required: false
|
||||
type: string
|
||||
default: "anthropic/claude-3-5-sonnet-20241022"
|
||||
default: "anthropic/claude-3-7-sonnet-20250219"
|
||||
LLM_API_VERSION:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
@@ -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.34-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.35-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -52,17 +52,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -391,7 +391,7 @@ type = "noop"
|
||||
#[llm.condenser]
|
||||
#model = "gpt-4o"
|
||||
#temperature = 0.1
|
||||
#max_tokens = 1024
|
||||
#max_input_tokens = 1024
|
||||
|
||||
#################################### Eval ####################################
|
||||
# Configuration for the evaluation, please refer to the specific evaluation
|
||||
|
||||
@@ -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.34-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.35-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
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.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ OpenHandsがリポジトリで動作する際:
|
||||
|
||||
1. リポジトリに`.openhands/microagents/`が存在する場合、そこからリポジトリ固有の指示を読み込みます。
|
||||
2. 会話のキーワードによってトリガーされる一般的なガイドラインを読み込みます。
|
||||
現在の[パブリックMicroagents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)を参照してください。
|
||||
現在の[パブリックMicroagents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)を参照してください。
|
||||
|
||||
## Microagentのフォーマット
|
||||
|
||||
|
||||
@@ -88,4 +88,4 @@ triggers:
|
||||
- ビルド時間とイメージサイズを最適化
|
||||
```
|
||||
|
||||
より多くの例については、[現在のパブリックマイクロエージェント](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)をご覧ください。
|
||||
より多くの例については、[現在のパブリックマイクロエージェント](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)をご覧ください。
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
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.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
Você encontrará o OpenHands em execução em http://localhost:3000!
|
||||
|
||||
@@ -13,7 +13,7 @@ Quando o OpenHands trabalha com um repositório, ele:
|
||||
|
||||
1. Carrega instruções específicas do repositório de `.openhands/microagents/`, se presentes no repositório.
|
||||
2. Carrega diretrizes gerais acionadas por palavras-chave nas conversas.
|
||||
Veja os [Microagentes Públicos](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) atuais.
|
||||
Veja os [Microagentes Públicos](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents) atuais.
|
||||
|
||||
## Formato do Microagente
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Microagentes públicos são diretrizes especializadas acionadas por palavras-chave para todos os usuários do OpenHands.
|
||||
Eles são definidos em arquivos markdown no diretório
|
||||
[`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
|
||||
[`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents).
|
||||
|
||||
Microagentes públicos:
|
||||
- Monitoram comandos recebidos em busca de suas palavras-chave de acionamento.
|
||||
@@ -149,5 +149,5 @@ Lembre-se de:
|
||||
- Otimizar para tempo de build e tamanho da imagem
|
||||
```
|
||||
|
||||
Veja os [microagentes públicos atuais](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) para
|
||||
Veja os [microagentes públicos atuais](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents) para
|
||||
mais exemplos.
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
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.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
@@ -46,4 +46,4 @@ Keyword-triggered microagents:
|
||||
- Apply their specialized knowledge and capabilities.
|
||||
- Follow defined guidelines and restrictions.
|
||||
|
||||
[See examples of microagents triggered by keywords in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
|
||||
[See examples of microagents triggered by keywords in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { it, describe, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AuthModal } from "#/components/features/waitlist/auth-modal";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import * as AuthHook from "#/context/auth-context";
|
||||
|
||||
// Mock the useAuthUrl hook
|
||||
vi.mock("#/hooks/use-auth-url", () => ({
|
||||
useAuthUrl: () => "https://gitlab.com/oauth/authorize"
|
||||
}));
|
||||
|
||||
describe("AuthModal", () => {
|
||||
beforeAll(() => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
vi.spyOn(AuthHook, "useAuth").mockReturnValue({
|
||||
providersAreSet: false,
|
||||
@@ -16,50 +20,29 @@ describe("AuthModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render a tos checkbox that is unchecked by default", () => {
|
||||
render(<AuthModal githubAuthUrl={null} appMode="saas" />);
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should only enable the identity provider buttons if the tos checkbox is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AuthModal githubAuthUrl={null} appMode="saas" />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
it("should render the GitHub and GitLab buttons", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
|
||||
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
|
||||
const gitlabButton = screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" });
|
||||
|
||||
expect(githubButton).toBeDisabled();
|
||||
expect(gitlabButton).toBeDisabled();
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(githubButton).not.toBeDisabled();
|
||||
expect(gitlabButton).not.toBeDisabled();
|
||||
expect(githubButton).toBeInTheDocument();
|
||||
expect(gitlabButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should set user analytics consent to true when the user checks the tos checkbox", async () => {
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
CaptureConsent,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
|
||||
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
const mockUrl = "https://github.com/login/oauth/authorize";
|
||||
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await user.click(checkbox);
|
||||
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
|
||||
await user.click(githubButton);
|
||||
|
||||
const button = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
|
||||
await user.click(button);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
expect(window.location.href).toBe(mockUrl);
|
||||
});
|
||||
});
|
||||
|
||||
136
frontend/__tests__/routes/accept-tos.test.tsx
Normal file
136
frontend/__tests__/routes/accept-tos.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AcceptTOS from "#/routes/accept-tos";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
|
||||
// Mock the react-router hooks
|
||||
vi.mock("react-router", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearchParams: () => [
|
||||
{
|
||||
get: (param: string) => {
|
||||
if (param === "redirect_url") {
|
||||
return "/dashboard";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Mock the axios instance
|
||||
vi.mock("#/api/open-hands-axios", () => ({
|
||||
openHands: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the toast handlers
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// Create a wrapper with QueryClientProvider
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("AcceptTOS", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render a TOS checkbox that is unchecked by default", () => {
|
||||
render(<AcceptTOS />, { wrapper: createWrapper() });
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
|
||||
|
||||
expect(checkbox).not.toBeChecked();
|
||||
expect(continueButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable the continue button when the TOS checkbox is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AcceptTOS />, { wrapper: createWrapper() });
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
|
||||
|
||||
expect(continueButton).toBeDisabled();
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(continueButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should set user analytics consent to true when the user accepts TOS", async () => {
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
CaptureConsent,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
|
||||
// Mock the API response
|
||||
vi.mocked(openHands.post).mockResolvedValue({
|
||||
data: { redirect_url: "/dashboard" },
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AcceptTOS />, { wrapper: createWrapper() });
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await user.click(checkbox);
|
||||
|
||||
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
|
||||
await user.click(continueButton);
|
||||
|
||||
// Wait for the mutation to complete
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
expect(openHands.post).toHaveBeenCalledWith("/api/accept_tos", {
|
||||
redirect_url: "/dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle external redirect URLs", async () => {
|
||||
// Mock the API response with an external URL
|
||||
const externalUrl = "https://example.com/callback";
|
||||
vi.mocked(openHands.post).mockResolvedValue({
|
||||
data: { redirect_url: externalUrl },
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AcceptTOS />, { wrapper: createWrapper() });
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await user.click(checkbox);
|
||||
|
||||
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
|
||||
await user.click(continueButton);
|
||||
|
||||
// Wait for the mutation to complete
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(window.location.href).toBe(externalUrl);
|
||||
});
|
||||
});
|
||||
291
frontend/__tests__/routes/app-settings.test.tsx
Normal file
291
frontend/__tests__/routes/app-settings.test.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AppSettingsScreen from "#/routes/app-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
const renderAppSettingsScreen = () =>
|
||||
render(<AppSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render the screen", () => {
|
||||
renderAppSettingsScreen();
|
||||
screen.getByTestId("app-settings-screen");
|
||||
});
|
||||
|
||||
it("should render the correct default values", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
language: "no",
|
||||
user_consents_to_analytics: true,
|
||||
enable_sound_notifications: true,
|
||||
});
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const language = screen.getByTestId("language-input");
|
||||
const analytics = screen.getByTestId("enable-analytics-switch");
|
||||
const sound = screen.getByTestId("enable-sound-notifications-switch");
|
||||
|
||||
expect(language).toHaveValue("Norsk");
|
||||
expect(analytics).toBeChecked();
|
||||
expect(sound).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the language options", async () => {
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const language = await screen.findByTestId("language-input");
|
||||
await userEvent.click(language);
|
||||
|
||||
AvailableLanguages.forEach((lang) => {
|
||||
const option = screen.getByText(lang.label);
|
||||
expect(option).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form submission", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should submit the form with the correct values", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const language = await screen.findByTestId("language-input");
|
||||
const analytics = await screen.findByTestId("enable-analytics-switch");
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
|
||||
expect(language).toHaveValue("English");
|
||||
expect(analytics).not.toBeChecked();
|
||||
expect(sound).not.toBeChecked();
|
||||
|
||||
// change language
|
||||
await userEvent.click(language);
|
||||
const norsk = screen.getByText("Norsk");
|
||||
await userEvent.click(norsk);
|
||||
expect(language).toHaveValue("Norsk");
|
||||
|
||||
// toggle options
|
||||
await userEvent.click(analytics);
|
||||
expect(analytics).toBeChecked();
|
||||
await userEvent.click(sound);
|
||||
expect(sound).toBeChecked();
|
||||
|
||||
// submit the form
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
language: "no",
|
||||
user_consents_to_analytics: true,
|
||||
enable_sound_notifications: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should only enable the submit button when there are changes", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
// Language check
|
||||
const language = await screen.findByTestId("language-input");
|
||||
await userEvent.click(language);
|
||||
const norsk = screen.getByText("Norsk");
|
||||
await userEvent.click(norsk);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(language);
|
||||
const english = screen.getByText("English");
|
||||
await userEvent.click(english);
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
// Analytics check
|
||||
const analytics = await screen.findByTestId("enable-analytics-switch");
|
||||
await userEvent.click(analytics);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(analytics);
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
// Sound check
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(sound);
|
||||
expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsents with true when the analytics switch is toggled", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const handleCaptureConsentsSpy = vi.spyOn(
|
||||
CaptureConsent,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const analytics = await screen.findByTestId("enable-analytics-switch");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.click(analytics);
|
||||
await userEvent.click(submit);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsents with false when the analytics switch is toggled", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
|
||||
const handleCaptureConsentsSpy = vi.spyOn(
|
||||
CaptureConsent,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const analytics = await screen.findByTestId("enable-analytics-switch");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.click(analytics);
|
||||
await userEvent.click(submit);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
|
||||
);
|
||||
});
|
||||
|
||||
// flaky test
|
||||
it.skip("should disable the button when submitting changes", async () => {
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(submit).toHaveTextContent("Saving...");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await waitFor(() => expect(submit).toHaveTextContent("Save"));
|
||||
});
|
||||
|
||||
it("should disable the button after submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(submit).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status toasts", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
461
frontend/__tests__/routes/git-settings.test.tsx
Normal file
461
frontend/__tests__/routes/git-settings.test.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
const VALID_OSS_CONFIG: GetConfigResponse = {
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
},
|
||||
};
|
||||
|
||||
const VALID_SAAS_CONFIG: GetConfigResponse = {
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
},
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const GitSettingsRouterStub = createRoutesStub([
|
||||
{
|
||||
Component: GitSettingsScreen,
|
||||
path: "/settings/github",
|
||||
},
|
||||
]);
|
||||
|
||||
const renderGitSettingsScreen = () => {
|
||||
const { rerender, ...rest } = render(
|
||||
<GitSettingsRouterStub initialEntries={["/settings/github"]} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderGitSettingsScreen = () =>
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<GitSettingsRouterStub initialEntries={["/settings/github"]} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
rerender: rerenderGitSettingsScreen,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Since we don't recreate the query client on every test, we need to
|
||||
// reset the query client before each test to avoid state leaks
|
||||
// between tests.
|
||||
queryClient.invalidateQueries();
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render", async () => {
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
});
|
||||
|
||||
it("should render the inputs if OSS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
|
||||
await screen.findByTestId("github-token-input");
|
||||
await screen.findByTestId("github-token-help-anchor");
|
||||
|
||||
await screen.findByTestId("gitlab-token-input");
|
||||
await screen.findByTestId("gitlab-token-help-anchor");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("github-token-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("github-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("gitlab-token-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("gitlab-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should set '<hidden>' placeholder and indicator if the GitHub token is set", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: false,
|
||||
gitlab: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const githubInput = screen.getByTestId("github-token-input");
|
||||
expect(githubInput).toHaveProperty("placeholder", "");
|
||||
expect(
|
||||
screen.queryByTestId("gh-set-token-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const gitlabInput = screen.getByTestId("gitlab-token-input");
|
||||
expect(gitlabInput).toHaveProperty("placeholder", "");
|
||||
expect(
|
||||
screen.queryByTestId("gl-set-token-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: true,
|
||||
gitlab: true,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
const githubInput = screen.getByTestId("github-token-input");
|
||||
expect(githubInput).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(
|
||||
screen.queryByTestId("gh-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const gitlabInput = screen.getByTestId("gitlab-token-input");
|
||||
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(
|
||||
screen.queryByTestId("gl-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: false,
|
||||
gitlab: true,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
const githubInput = screen.getByTestId("github-token-input");
|
||||
expect(githubInput).toHaveProperty("placeholder", "");
|
||||
expect(
|
||||
screen.queryByTestId("gh-set-token-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const gitlabInput = screen.getByTestId("gitlab-token-input");
|
||||
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(
|
||||
screen.queryByTestId("gl-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
|
||||
let button = screen.queryByTestId("configure-github-repositories-button");
|
||||
expect(button).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("disconnect-tokens-button")).toBeInTheDocument();
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
// wait until queries are resolved
|
||||
expect(queryClient.isFetching()).toBe(0);
|
||||
button = screen.queryByTestId("configure-github-repositories-button");
|
||||
expect(button).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
APP_SLUG: "test-slug",
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
button = screen.getByTestId("configure-github-repositories-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("disconnect-tokens-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form submission", () => {
|
||||
it("should save the GitHub token", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider_tokens: {
|
||||
github: "test-token",
|
||||
gitlab: "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider_tokens: {
|
||||
github: "",
|
||||
gitlab: "test-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable the button if there is no input", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(githubInput);
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(gitlabInput);
|
||||
expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable a disconnect tokens button if there is at least one token set", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: true,
|
||||
gitlab: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
let disconnectButton = await screen.findByTestId(
|
||||
"disconnect-tokens-button",
|
||||
);
|
||||
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: false,
|
||||
gitlab: false,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
|
||||
await waitFor(() => expect(disconnectButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("should call logout when pressing the disconnect tokens button", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const logoutSpy = vi.spyOn(OpenHands, "logout");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: true,
|
||||
gitlab: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const disconnectButton = await screen.findByTestId(
|
||||
"disconnect-tokens-button",
|
||||
);
|
||||
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
|
||||
await userEvent.click(disconnectButton);
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// flaky test
|
||||
it.skip("should disable the button when submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
|
||||
expect(submit).toHaveTextContent("Saving...");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await waitFor(() => expect(submit).toHaveTextContent("Save"));
|
||||
});
|
||||
|
||||
it("should disable the button after submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
expect(gitlabInput).toHaveValue("test-token");
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(submit).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status toasts", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,13 @@ describe("HomeScreen", () => {
|
||||
screen.getByTestId("task-suggestions");
|
||||
});
|
||||
|
||||
it("should have responsive layout for mobile and desktop screens", async () => {
|
||||
renderHomeScreen();
|
||||
|
||||
const mainContainer = screen.getByTestId("home-screen").querySelector("main");
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "md:flex-row");
|
||||
});
|
||||
|
||||
it("should filter the suggested tasks based on the selected repository", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
|
||||
674
frontend/__tests__/routes/llm-settings.test.tsx
Normal file
674
frontend/__tests__/routes/llm-settings.test.tsx
Normal file
@@ -0,0 +1,674 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import LlmSettingsScreen from "#/routes/llm-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
MOCK_DEFAULT_USER_SETTINGS,
|
||||
resetTestHandlersMockSettings,
|
||||
} from "#/mocks/handlers";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
const renderLlmSettingsScreen = () =>
|
||||
render(<LlmSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
resetTestHandlersMockSettings();
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
describe("Basic form", () => {
|
||||
it("should render the basic form by default", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicFom = screen.getByTestId("llm-settings-form-basic");
|
||||
within(basicFom).getByTestId("llm-provider-input");
|
||||
within(basicFom).getByTestId("llm-model-input");
|
||||
within(basicFom).getByTestId("llm-api-key-input");
|
||||
within(basicFom).getByTestId("llm-api-key-help-anchor");
|
||||
});
|
||||
|
||||
it("should render the default values if non exist", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("Anthropic");
|
||||
expect(model).toHaveValue("claude-3-5-sonnet-20241022");
|
||||
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the existing settings values", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_api_key_set: true,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
expect(model).toHaveValue("gpt-4o");
|
||||
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should render the advanced form if the switch is toggled", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-form-advanced"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(basicForm).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-form-advanced"),
|
||||
).toBeInTheDocument();
|
||||
expect(basicForm).not.toBeInTheDocument();
|
||||
|
||||
const advancedForm = screen.getByTestId("llm-settings-form-advanced");
|
||||
within(advancedForm).getByTestId("llm-custom-model-input");
|
||||
within(advancedForm).getByTestId("base-url-input");
|
||||
within(advancedForm).getByTestId("llm-api-key-input");
|
||||
within(advancedForm).getByTestId("llm-api-key-help-anchor");
|
||||
within(advancedForm).getByTestId("agent-input");
|
||||
within(advancedForm).getByTestId("enable-confirmation-mode-switch");
|
||||
within(advancedForm).getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-form-advanced"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("llm-settings-form-basic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the default advanced settings", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
expect(advancedSwitch).not.toBeChecked();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
const baseUrl = screen.getByTestId("base-url-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const confirmation = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
expect(model).toHaveValue("anthropic/claude-3-5-sonnet-20241022");
|
||||
expect(baseUrl).toHaveValue("");
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "");
|
||||
expect(agent).toHaveValue("CodeActAgent");
|
||||
expect(confirmation).not.toBeChecked();
|
||||
expect(condensor).toBeChecked();
|
||||
|
||||
// check that security analyzer is present
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.click(confirmation);
|
||||
screen.getByTestId("security-analyzer-input");
|
||||
});
|
||||
|
||||
it("should render the advanced form if existings settings are advanced", async () => {
|
||||
const hasAdvancedSettingsSetSpy = vi.spyOn(
|
||||
AdvancedSettingsUtlls,
|
||||
"hasAdvancedSettingsSet",
|
||||
);
|
||||
hasAdvancedSettingsSetSpy.mockReturnValue(true);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
expect(advancedSwitch).toBeChecked();
|
||||
screen.getByTestId("llm-settings-form-advanced");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render existing advanced settings correctly", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_base_url: "https://api.openai.com/v1/chat/completions",
|
||||
llm_api_key_set: true,
|
||||
agent: "CoActAgent",
|
||||
confirmation_mode: true,
|
||||
enable_default_condenser: false,
|
||||
security_analyzer: "mock-invariant",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
const baseUrl = screen.getByTestId("base-url-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const confirmation = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(model).toHaveValue("openai/gpt-4o");
|
||||
expect(baseUrl).toHaveValue(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
);
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(agent).toHaveValue("CoActAgent");
|
||||
expect(confirmation).toBeChecked();
|
||||
expect(condensor).not.toBeChecked();
|
||||
expect(securityAnalyzer).toHaveValue("mock-invariant");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.todo("should render an indicator if the llm api key is set");
|
||||
});
|
||||
|
||||
describe("Form submission", () => {
|
||||
it("should submit the basic form with the correct values", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
// select provider
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
|
||||
// enter api key
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
|
||||
// select model
|
||||
await userEvent.click(model);
|
||||
const modelOption = screen.getByText("gpt-4o");
|
||||
await userEvent.click(modelOption);
|
||||
expect(model).toHaveValue("gpt-4o");
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_api_key: "test-api-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should submit the advanced form with the correct values", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
const baseUrl = screen.getByTestId("base-url-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
// enter custom model
|
||||
await userEvent.clear(model);
|
||||
await userEvent.type(model, "openai/gpt-4o");
|
||||
expect(model).toHaveValue("openai/gpt-4o");
|
||||
|
||||
// enter base url
|
||||
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
|
||||
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
|
||||
|
||||
// enter api key
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
|
||||
// toggle confirmation mode
|
||||
await userEvent.click(confirmation);
|
||||
expect(confirmation).toBeChecked();
|
||||
|
||||
// toggle memory condensor
|
||||
await userEvent.click(condensor);
|
||||
expect(condensor).not.toBeChecked();
|
||||
|
||||
// select agent
|
||||
await userEvent.click(agent);
|
||||
const agentOption = screen.getByText("CoActAgent");
|
||||
await userEvent.click(agentOption);
|
||||
expect(agent).toHaveValue("CoActAgent");
|
||||
|
||||
// select security analyzer
|
||||
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const securityAnalyzerOption = screen.getByText("mock-invariant");
|
||||
await userEvent.click(securityAnalyzerOption);
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_base_url: "https://api.openai.com/v1/chat/completions",
|
||||
agent: "CoActAgent",
|
||||
confirmation_mode: true,
|
||||
enable_default_condenser: false,
|
||||
security_analyzer: "mock-invariant",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable the button if there are no changes in the basic form", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_api_key_set: true,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
screen.getByTestId("llm-settings-form-basic");
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
// select model
|
||||
await userEvent.click(model);
|
||||
const modelOption = screen.getByText("gpt-4o-mini");
|
||||
await userEvent.click(modelOption);
|
||||
expect(model).toHaveValue("gpt-4o-mini");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset model
|
||||
await userEvent.click(model);
|
||||
const modelOption2 = screen.getByText("gpt-4o");
|
||||
await userEvent.click(modelOption2);
|
||||
expect(model).toHaveValue("gpt-4o");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// set api key
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
expect(apiKey).toHaveValue("test-api-key");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset api key
|
||||
await userEvent.clear(apiKey);
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable the button if there are no changes in the advanced form", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_base_url: "https://api.openai.com/v1/chat/completions",
|
||||
llm_api_key_set: true,
|
||||
confirmation_mode: true,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
screen.getByTestId("llm-settings-form-advanced");
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
const baseUrl = screen.getByTestId("base-url-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
// enter custom model
|
||||
await userEvent.type(model, "-mini");
|
||||
expect(model).toHaveValue("openai/gpt-4o-mini");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset model
|
||||
await userEvent.clear(model);
|
||||
expect(model).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
await userEvent.type(model, "openai/gpt-4o");
|
||||
expect(model).toHaveValue("openai/gpt-4o");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// enter base url
|
||||
await userEvent.type(baseUrl, "/extra");
|
||||
expect(baseUrl).toHaveValue(
|
||||
"https://api.openai.com/v1/chat/completions/extra",
|
||||
);
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(baseUrl);
|
||||
expect(baseUrl).toHaveValue("");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
|
||||
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// set api key
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
expect(apiKey).toHaveValue("test-api-key");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset api key
|
||||
await userEvent.clear(apiKey);
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// set agent
|
||||
await userEvent.clear(agent);
|
||||
await userEvent.type(agent, "test-agent");
|
||||
expect(agent).toHaveValue("test-agent");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset agent
|
||||
await userEvent.clear(agent);
|
||||
expect(agent).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
await userEvent.type(agent, "CodeActAgent");
|
||||
expect(agent).toHaveValue("CodeActAgent");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// toggle confirmation mode
|
||||
await userEvent.click(confirmation);
|
||||
expect(confirmation).not.toBeChecked();
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
await userEvent.click(confirmation);
|
||||
expect(confirmation).toBeChecked();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// toggle memory condensor
|
||||
await userEvent.click(condensor);
|
||||
expect(condensor).not.toBeChecked();
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
await userEvent.click(condensor);
|
||||
expect(condensor).toBeChecked();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// select security analyzer
|
||||
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const securityAnalyzerOption = screen.getByText("mock-invariant");
|
||||
await userEvent.click(securityAnalyzerOption);
|
||||
expect(securityAnalyzer).toHaveValue("mock-invariant");
|
||||
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(securityAnalyzer);
|
||||
expect(securityAnalyzer).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should reset button state when switching between forms", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// dirty the basic form
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// dirty the advanced form
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
await userEvent.type(model, "openai/gpt-4o");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
// flaky test
|
||||
it.skip("should disable the button when submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_api_key: "test-api-key",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(submitButton).toHaveTextContent("Saving...");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).toHaveTextContent("Save");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status toasts", () => {
|
||||
describe("Basic form", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
// Toggle setting to change
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
// Toggle setting to change
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SaaS mode", () => {
|
||||
it("should not render the runtime settings input in oss mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
|
||||
expect(runtimeSettingsInput).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the runtime settings input in saas mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
|
||||
expect(runtimeSettingsInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should always render the runtime settings input as disabled", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
|
||||
expect(runtimeSettingsInput).toBeInTheDocument();
|
||||
expect(runtimeSettingsInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
@@ -7,6 +7,30 @@ import OpenHands from "#/api/open-hands";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
|
||||
// Mock the i18next hook
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"SETTINGS$NAV_GIT": "Git",
|
||||
"SETTINGS$NAV_APPLICATION": "Application",
|
||||
"SETTINGS$NAV_CREDITS": "Credits",
|
||||
"SETTINGS$NAV_API_KEYS": "API Keys",
|
||||
"SETTINGS$NAV_LLM": "LLM",
|
||||
"SETTINGS$TITLE": "Settings"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Settings Billing", () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
@@ -19,18 +43,22 @@ describe("Settings Billing", () => {
|
||||
Component: () => <PaymentForm />,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = () =>
|
||||
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
|
||||
renderWithProviders(<RoutesStub initialEntries={["/settings/billing"]} />);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should not render the navbar if OSS mode", async () => {
|
||||
it("should not render the credits tab if OSS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
@@ -43,15 +71,12 @@ describe("Settings Billing", () => {
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
// Wait for the settings screen to be rendered
|
||||
await screen.findByTestId("settings-screen");
|
||||
|
||||
// Then check that the navbar is not present
|
||||
const navbar = screen.queryByTestId("settings-navbar");
|
||||
expect(navbar).not.toBeInTheDocument();
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
const credits = within(navbar).queryByText("Credits");
|
||||
expect(credits).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the navbar if SaaS mode", async () => {
|
||||
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
@@ -64,11 +89,8 @@ describe("Settings Billing", () => {
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const navbar = screen.getByTestId("settings-navbar");
|
||||
within(navbar).getByText("Account");
|
||||
within(navbar).getByText("Credits");
|
||||
});
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
within(navbar).getByText("Credits");
|
||||
});
|
||||
|
||||
it("should render the billing settings if clicking the credits item", async () => {
|
||||
@@ -90,6 +112,6 @@ describe("Settings Billing", () => {
|
||||
await user.click(credits);
|
||||
|
||||
const billingSection = await screen.findByTestId("billing-settings");
|
||||
within(billingSection).getByText("PAYMENT$MANAGE_CREDITS");
|
||||
expect(billingSection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,10 @@ describe("hasAdvancedSettingsSet", () => {
|
||||
expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if an empty object", () => {
|
||||
expect(hasAdvancedSettingsSet({})).toBe(false);
|
||||
});
|
||||
|
||||
describe("should be true if", () => {
|
||||
test("LLM_BASE_URL is set", () => {
|
||||
expect(
|
||||
@@ -26,15 +30,6 @@ describe("hasAdvancedSettingsSet", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("REMOTE_RUNTIME_RESOURCE_FACTOR is not default value", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: 999,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("CONFIRMATION_MODE is true", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
|
||||
3623
frontend/package-lock.json
generated
3623
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.7.6",
|
||||
"@heroui/react": "2.7.8",
|
||||
"@microlink/react-json-view": "^1.26.1",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.5.2",
|
||||
"@react-router/serve": "^7.5.2",
|
||||
"@react-router/node": "^7.5.3",
|
||||
"@react-router/serve": "^7.5.3",
|
||||
"@react-types/shared": "^3.29.0",
|
||||
"@reduxjs/toolkit": "^2.7.0",
|
||||
"@stripe/react-stripe-js": "^3.6.0",
|
||||
"@stripe/stripe-js": "^7.2.0",
|
||||
"@tanstack/react-query": "^5.74.7",
|
||||
"@tanstack/react-query": "^5.74.9",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
@@ -24,14 +24,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.9.2",
|
||||
"i18next": "^25.0.1",
|
||||
"i18next": "^25.0.2",
|
||||
"i18next-browser-languagedetector": "^8.0.5",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.27",
|
||||
"jose": "^6.0.10",
|
||||
"lucide-react": "^0.503.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.236.7",
|
||||
"posthog-js": "^1.237.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -40,7 +40,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.5.2",
|
||||
"react-router": "^7.5.3",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -82,7 +82,7 @@
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@react-router/dev": "^7.5.2",
|
||||
"@react-router/dev": "^7.5.3",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.74.7",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
|
||||
@@ -107,6 +107,10 @@ function isRawTranslationKey(str) {
|
||||
const EXCLUDED_TECHNICAL_STRINGS = [
|
||||
"openid email profile", // OAuth scope string - not user-facing
|
||||
"OPEN_ISSUE", // Task type identifier, not a UI string
|
||||
"Merge Request", // Git provider specific terminology
|
||||
"GitLab API", // Git provider specific terminology
|
||||
"Pull Request", // Git provider specific terminology
|
||||
"GitHub API", // Git provider specific terminology
|
||||
];
|
||||
|
||||
function isExcludedTechnicalString(str) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DiffEditor } from "@monaco-editor/react";
|
||||
import { DiffEditor, Monaco } from "@monaco-editor/react";
|
||||
import React from "react";
|
||||
import { editor as editor_t } from "monaco-editor";
|
||||
import { LuFileDiff, LuFileMinus, LuFilePlus } from "react-icons/lu";
|
||||
@@ -88,6 +88,29 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const beforeMount = (monaco: Monaco) => {
|
||||
monaco.editor.defineTheme("custom-diff-theme", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "comment", foreground: "6a9955" },
|
||||
{ token: "keyword", foreground: "569cd6" },
|
||||
{ token: "string", foreground: "ce9178" },
|
||||
{ token: "number", foreground: "b5cea8" },
|
||||
],
|
||||
colors: {
|
||||
"diffEditor.insertedTextBackground": "#014b01AA", // Stronger green background
|
||||
"diffEditor.removedTextBackground": "#750000AA", // Stronger red background
|
||||
"diffEditor.insertedLineBackground": "#003f00AA", // Dark green for added lines
|
||||
"diffEditor.removedLineBackground": "#5a0000AA", // Dark red for removed lines
|
||||
"diffEditor.border": "#444444", // Border between diff editors
|
||||
|
||||
"editorUnnecessaryCode.border": "#00000000", // No border for unnecessary code
|
||||
"editorUnnecessaryCode.opacity": "#00000077", // Slightly faded
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditorDidMount = (editor: editor_t.IStandaloneDiffEditor) => {
|
||||
diffEditorRef.current = editor;
|
||||
updateEditorHeight();
|
||||
@@ -145,8 +168,9 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
|
||||
language={getLanguageFromPath(filePath)}
|
||||
original={isAdded ? "" : diff.original}
|
||||
modified={isDeleted ? "" : diff.modified}
|
||||
theme="vs-dark"
|
||||
theme="custom-diff-theme"
|
||||
onMount={handleEditorDidMount}
|
||||
beforeMount={beforeMount}
|
||||
options={{
|
||||
renderValidationDecorations: "off",
|
||||
readOnly: true,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { InputSkeleton } from "../input-skeleton";
|
||||
import { SwitchSkeleton } from "../switch-skeleton";
|
||||
|
||||
export function AppSettingsInputsSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-testid="app-settings-skeleton"
|
||||
className="px-11 py-9 flex flex-col gap-6"
|
||||
>
|
||||
<InputSkeleton />
|
||||
<SwitchSkeleton />
|
||||
<SwitchSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsDropdownInput } from "../settings-dropdown-input";
|
||||
|
||||
interface LanguageInputProps {
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
defaultKey: string;
|
||||
}
|
||||
|
||||
export function LanguageInput({
|
||||
defaultKey,
|
||||
onChange,
|
||||
name,
|
||||
}: LanguageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SettingsDropdownInput
|
||||
testId={name}
|
||||
name={name}
|
||||
onInputChange={onChange}
|
||||
label={t(I18nKey.SETTINGS$LANGUAGE)}
|
||||
items={AvailableLanguages.map((l) => ({
|
||||
key: l.value,
|
||||
label: l.label,
|
||||
}))}
|
||||
defaultSelectedKey={defaultKey}
|
||||
isClearable={false}
|
||||
wrapperClassName="w-[680px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { cn } from "#/utils/utils";
|
||||
|
||||
interface BrandButtonProps {
|
||||
testId?: string;
|
||||
name?: string;
|
||||
variant: "primary" | "secondary" | "danger";
|
||||
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
isDisabled?: boolean;
|
||||
@@ -12,6 +13,7 @@ interface BrandButtonProps {
|
||||
|
||||
export function BrandButton({
|
||||
testId,
|
||||
name,
|
||||
children,
|
||||
variant,
|
||||
type,
|
||||
@@ -22,6 +24,7 @@ export function BrandButton({
|
||||
}: React.PropsWithChildren<BrandButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
name={name}
|
||||
data-testid={testId}
|
||||
disabled={isDisabled}
|
||||
// The type is alreadt passed as a prop to the button component
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
|
||||
interface ConfigureGitHubRepositoriesAnchorProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function ConfigureGitHubRepositoriesAnchor({
|
||||
slug,
|
||||
}: ConfigureGitHubRepositoriesAnchorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<a
|
||||
data-testid="configure-github-repositories-button"
|
||||
href={`https://github.com/apps/${slug}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="px-11 py-9"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</BrandButton>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { InputSkeleton } from "../input-skeleton";
|
||||
import { SubtextSkeleton } from "../subtext-skeleton";
|
||||
|
||||
export function GitSettingInputsSkeleton() {
|
||||
return (
|
||||
<div className="px-11 py-9 flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputSkeleton />
|
||||
<SubtextSkeleton />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputSkeleton />
|
||||
<SubtextSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function GitHubTokenHelpAnchor() {
|
||||
return (
|
||||
<p data-testid="github-token-help-anchor" className="text-xs">
|
||||
<Trans
|
||||
i18nKey={I18nKey.GITHUB$TOKEN_HELP_TEXT}
|
||||
components={[
|
||||
<a
|
||||
key="github-token-help-anchor-link"
|
||||
aria-label="GitHub token help link"
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
<a
|
||||
key="github-token-help-anchor-link-2"
|
||||
aria-label="GitHub token see more link"
|
||||
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
import { GitHubTokenHelpAnchor } from "./github-token-help-anchor";
|
||||
import { KeyStatusIcon } from "../key-status-icon";
|
||||
|
||||
interface GitHubTokenInputProps {
|
||||
onChange: (value: string) => void;
|
||||
isGitHubTokenSet: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function GitHubTokenInput({
|
||||
onChange,
|
||||
isGitHubTokenSet,
|
||||
name,
|
||||
}: GitHubTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsInput
|
||||
testId={name}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
|
||||
startContent={
|
||||
isGitHubTokenSet && (
|
||||
<KeyStatusIcon
|
||||
testId="gh-set-token-indicator"
|
||||
isSet={isGitHubTokenSet}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<GitHubTokenHelpAnchor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function GitLabTokenHelpAnchor() {
|
||||
return (
|
||||
<p data-testid="gitlab-token-help-anchor" className="text-xs">
|
||||
<Trans
|
||||
i18nKey={I18nKey.GITLAB$TOKEN_HELP_TEXT}
|
||||
components={[
|
||||
<a
|
||||
key="gitlab-token-help-anchor-link"
|
||||
aria-label="Gitlab token help link"
|
||||
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
<a
|
||||
key="gitlab-token-help-anchor-link-2"
|
||||
aria-label="GitLab token see more link"
|
||||
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
import { GitLabTokenHelpAnchor } from "./gitlab-token-help-anchor";
|
||||
import { KeyStatusIcon } from "../key-status-icon";
|
||||
|
||||
interface GitLabTokenInputProps {
|
||||
onChange: (value: string) => void;
|
||||
isGitLabTokenSet: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function GitLabTokenInput({
|
||||
onChange,
|
||||
isGitLabTokenSet,
|
||||
name,
|
||||
}: GitLabTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsInput
|
||||
testId={name}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={isGitLabTokenSet ? "<hidden>" : ""}
|
||||
startContent={
|
||||
isGitLabTokenSet && (
|
||||
<KeyStatusIcon
|
||||
testId="gl-set-token-indicator"
|
||||
isSet={isGitLabTokenSet}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<GitLabTokenHelpAnchor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function InputSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="w-[70px] h-[20px] skeleton" />
|
||||
<div className="w-[680px] h-[40px] skeleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import SuccessIcon from "#/icons/success.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface KeyStatusIconProps {
|
||||
testId?: string;
|
||||
isSet: boolean;
|
||||
}
|
||||
|
||||
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
|
||||
export function KeyStatusIcon({ testId, isSet }: KeyStatusIconProps) {
|
||||
return (
|
||||
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
|
||||
<span data-testid={testId || (isSet ? "set-indicator" : "unset-indicator")}>
|
||||
<SuccessIcon className={cn(isSet ? "text-success" : "text-danger")} />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { InputSkeleton } from "../input-skeleton";
|
||||
import { SubtextSkeleton } from "../subtext-skeleton";
|
||||
import { SwitchSkeleton } from "../switch-skeleton";
|
||||
|
||||
export function LlmSettingsInputsSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-testid="app-settings-skeleton"
|
||||
className="px-11 py-9 flex flex-col gap-6"
|
||||
>
|
||||
<SwitchSkeleton />
|
||||
<InputSkeleton />
|
||||
<InputSkeleton />
|
||||
<InputSkeleton />
|
||||
<SubtextSkeleton />
|
||||
<SwitchSkeleton />
|
||||
<SwitchSkeleton />
|
||||
<InputSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
|
||||
interface ResetSettingsModalProps {
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<div className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary">
|
||||
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
|
||||
<div className="w-full flex gap-2" data-testid="reset-settings-modal">
|
||||
<BrandButton
|
||||
testId="confirm-button"
|
||||
type="submit"
|
||||
name="reset-settings"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
testId="cancel-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={onReset}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ interface SettingsSwitchProps {
|
||||
name?: string;
|
||||
onToggle?: (value: boolean) => void;
|
||||
defaultIsToggled?: boolean;
|
||||
isToggled?: boolean;
|
||||
isBeta?: boolean;
|
||||
}
|
||||
|
||||
@@ -15,6 +16,7 @@ export function SettingsSwitch({
|
||||
name,
|
||||
onToggle,
|
||||
defaultIsToggled,
|
||||
isToggled: controlledIsToggled,
|
||||
isBeta,
|
||||
}: React.PropsWithChildren<SettingsSwitchProps>) {
|
||||
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
|
||||
@@ -25,17 +27,18 @@ export function SettingsSwitch({
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 w-fit">
|
||||
<label className="flex items-center gap-2 w-fit cursor-pointer">
|
||||
<input
|
||||
hidden
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
onChange={(e) => handleToggle(e.target.checked)}
|
||||
checked={controlledIsToggled ?? isToggled}
|
||||
defaultChecked={defaultIsToggled}
|
||||
/>
|
||||
|
||||
<StyledSwitchComponent isToggled={isToggled} />
|
||||
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{children}</span>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function SubtextSkeleton() {
|
||||
return <div className="w-[250px] h-[20px] skeleton" />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function SwitchSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-[48px] h-[24px] skeleton-round" />
|
||||
<div className="w-[100px] h-[20px] skeleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,6 @@ 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";
|
||||
import { TOSCheckbox } from "./tos-checkbox";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
|
||||
@@ -19,7 +17,6 @@ interface AuthModalProps {
|
||||
|
||||
export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||
|
||||
const gitlabAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
@@ -28,14 +25,14 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
handleCaptureConsent(true);
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = githubAuthUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitLabAuth = () => {
|
||||
if (gitlabAuthUrl) {
|
||||
handleCaptureConsent(true);
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = gitlabAuthUrl;
|
||||
}
|
||||
};
|
||||
@@ -50,11 +47,8 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<BrandButton
|
||||
isDisabled={!isTosAccepted}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleGitHubAuth}
|
||||
@@ -65,7 +59,6 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
isDisabled={!isTosAccepted}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleGitLabAuth}
|
||||
|
||||
@@ -14,12 +14,14 @@ interface ModelSelectorProps {
|
||||
isDisabled?: boolean;
|
||||
models: Record<string, { separator: string; models: string[] }>;
|
||||
currentModel?: string;
|
||||
onChange?: (model: string | null) => void;
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
isDisabled,
|
||||
models,
|
||||
currentModel,
|
||||
onChange,
|
||||
}: ModelSelectorProps) {
|
||||
const [, setLitellmId] = React.useState<string | null>(null);
|
||||
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
|
||||
@@ -55,6 +57,7 @@ export function ModelSelector({
|
||||
}
|
||||
setLitellmId(fullModel);
|
||||
setSelectedModel(model);
|
||||
onChange?.(fullModel);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
|
||||
@@ -13,21 +13,35 @@ import posthog from "posthog-js";
|
||||
import "./i18n";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import store from "./store";
|
||||
import { useConfig } from "./hooks/query/use-config";
|
||||
import { AuthProvider } from "./context/auth-context";
|
||||
import { queryClientConfig } from "./query-client-config";
|
||||
import OpenHands from "./api/open-hands";
|
||||
import { displayErrorToast } from "./utils/custom-toast-handlers";
|
||||
|
||||
function PosthogInit() {
|
||||
const { data: config } = useConfig();
|
||||
const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config?.POSTHOG_CLIENT_KEY) {
|
||||
posthog.init(config.POSTHOG_CLIENT_KEY, {
|
||||
(async () => {
|
||||
try {
|
||||
const config = await OpenHands.getConfig();
|
||||
setPosthogClientKey(config.POSTHOG_CLIENT_KEY);
|
||||
} catch (error) {
|
||||
displayErrorToast("Error fetching PostHog client key");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (posthogClientKey) {
|
||||
posthog.init(posthogClientKey, {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
}, [posthogClientKey]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useBalance = () => {
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "balance"],
|
||||
queryFn: OpenHands.getBalance,
|
||||
enabled:
|
||||
config?.APP_MODE === "saas" && config?.FEATURE_FLAGS.ENABLE_BILLING,
|
||||
!isOnTosPage &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
config?.FEATURE_FLAGS.ENABLE_BILLING,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useConfig = () =>
|
||||
useQuery({
|
||||
export const useConfig = () => {
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: OpenHands.getConfig,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes,
|
||||
enabled: !isOnTosPage,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,17 +3,19 @@ import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useIsAuthed = () => {
|
||||
const { providersAreSet } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "authenticated", providersAreSet, appMode],
|
||||
queryFn: () => OpenHands.authenticate(appMode!),
|
||||
enabled: !!appMode,
|
||||
enabled: !!appMode && !isOnTosPage,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
retry: false,
|
||||
|
||||
@@ -4,8 +4,10 @@ import posthog from "posthog-js";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
const getSettingsQueryFn = async () => {
|
||||
const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
const apiSettings = await OpenHands.getSettings();
|
||||
|
||||
return {
|
||||
@@ -30,6 +32,8 @@ export const useSettings = () => {
|
||||
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
|
||||
useAuth();
|
||||
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["settings", providerTokensSet],
|
||||
queryFn: getSettingsQueryFn,
|
||||
@@ -39,6 +43,7 @@ export const useSettings = () => {
|
||||
retry: (_, error) => error.status !== 404,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
enabled: !isOnTosPage,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
|
||||
11
frontend/src/hooks/use-is-on-tos-page.ts
Normal file
11
frontend/src/hooks/use-is-on-tos-page.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
/**
|
||||
* Hook to check if the current page is the Terms of Service acceptance page.
|
||||
*
|
||||
* @returns {boolean} True if the current page is the TOS acceptance page, false otherwise.
|
||||
*/
|
||||
export const useIsOnTosPage = (): boolean => {
|
||||
const { pathname } = useLocation();
|
||||
return pathname === "/accept-tos";
|
||||
};
|
||||
@@ -26,6 +26,17 @@ export enum I18nKey {
|
||||
ANALYTICS$DESCRIPTION = "ANALYTICS$DESCRIPTION",
|
||||
ANALYTICS$SEND_ANONYMOUS_DATA = "ANALYTICS$SEND_ANONYMOUS_DATA",
|
||||
ANALYTICS$CONFIRM_PREFERENCES = "ANALYTICS$CONFIRM_PREFERENCES",
|
||||
SETTINGS$SAVING = "SETTINGS$SAVING",
|
||||
SETTINGS$SAVE_CHANGES = "SETTINGS$SAVE_CHANGES",
|
||||
SETTINGS$NAV_GIT = "SETTINGS$NAV_GIT",
|
||||
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
|
||||
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
|
||||
SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS",
|
||||
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
|
||||
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
|
||||
GIT$GITLAB_API = "GIT$GITLAB_API",
|
||||
GIT$PULL_REQUEST = "GIT$PULL_REQUEST",
|
||||
GIT$GITHUB_API = "GIT$GITHUB_API",
|
||||
BUTTON$COPY = "BUTTON$COPY",
|
||||
BUTTON$COPIED = "BUTTON$COPIED",
|
||||
APP$TITLE = "APP$TITLE",
|
||||
@@ -95,6 +106,9 @@ export enum I18nKey {
|
||||
GITHUB$TOKEN_LABEL = "GITHUB$TOKEN_LABEL",
|
||||
GITHUB$TOKEN_OPTIONAL = "GITHUB$TOKEN_OPTIONAL",
|
||||
GITHUB$GET_TOKEN = "GITHUB$GET_TOKEN",
|
||||
GITHUB$TOKEN_HELP_TEXT = "GITHUB$TOKEN_HELP_TEXT",
|
||||
GITHUB$TOKEN_LINK_TEXT = "GITHUB$TOKEN_LINK_TEXT",
|
||||
GITHUB$INSTRUCTIONS_LINK_TEXT = "GITHUB$INSTRUCTIONS_LINK_TEXT",
|
||||
COMMON$HERE = "COMMON$HERE",
|
||||
ANALYTICS$ENABLE = "ANALYTICS$ENABLE",
|
||||
GITHUB$TOKEN_INVALID = "GITHUB$TOKEN_INVALID",
|
||||
@@ -437,6 +451,9 @@ export enum I18nKey {
|
||||
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
|
||||
GITLAB$TOKEN_LABEL = "GITLAB$TOKEN_LABEL",
|
||||
GITLAB$GET_TOKEN = "GITLAB$GET_TOKEN",
|
||||
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
|
||||
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
|
||||
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
|
||||
GITLAB$OR_SEE = "GITLAB$OR_SEE",
|
||||
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
|
||||
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",
|
||||
@@ -452,4 +469,8 @@ export enum I18nKey {
|
||||
SYSTEM_MESSAGE_MODAL$TOOLS_TAB = "SYSTEM_MESSAGE_MODAL$TOOLS_TAB",
|
||||
SYSTEM_MESSAGE_MODAL$PARAMETERS = "SYSTEM_MESSAGE_MODAL$PARAMETERS",
|
||||
SYSTEM_MESSAGE_MODAL$NO_TOOLS = "SYSTEM_MESSAGE_MODAL$NO_TOOLS",
|
||||
TOS$ACCEPT_TERMS_OF_SERVICE = "TOS$ACCEPT_TERMS_OF_SERVICE",
|
||||
TOS$ACCEPT_TERMS_DESCRIPTION = "TOS$ACCEPT_TERMS_DESCRIPTION",
|
||||
TOS$CONTINUE = "TOS$CONTINUE",
|
||||
TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING",
|
||||
}
|
||||
|
||||
@@ -389,6 +389,171 @@
|
||||
"tr": "Tercihleri Onayla",
|
||||
"de": "Einstellungen bestätigen"
|
||||
},
|
||||
"SETTINGS$SAVING": {
|
||||
"en": "Saving...",
|
||||
"ja": "保存中...",
|
||||
"zh-CN": "保存中...",
|
||||
"zh-TW": "儲存中...",
|
||||
"ko-KR": "저장 중...",
|
||||
"no": "Lagrer...",
|
||||
"it": "Salvataggio in corso...",
|
||||
"pt": "Salvando...",
|
||||
"es": "Guardando...",
|
||||
"ar": "جار الحفظ...",
|
||||
"fr": "Enregistrement en cours...",
|
||||
"tr": "Kayıt yapılıyor...",
|
||||
"de": "Speichern..."
|
||||
},
|
||||
"SETTINGS$SAVE_CHANGES": {
|
||||
"en": "Save Changes",
|
||||
"ja": "変更を保存",
|
||||
"zh-CN": "保存更改",
|
||||
"zh-TW": "儲存變更",
|
||||
"ko-KR": "변경 사항 저장",
|
||||
"no": "Lagre endringer",
|
||||
"it": "Salva modifiche",
|
||||
"pt": "Salvar alterações",
|
||||
"es": "Guardar cambios",
|
||||
"ar": "حفظ التغييرات",
|
||||
"fr": "Enregistrer les modifications",
|
||||
"tr": "Değişiklikleri Kaydet",
|
||||
"de": "Änderungen speichern"
|
||||
},
|
||||
"SETTINGS$NAV_GIT": {
|
||||
"en": "Git",
|
||||
"ja": "Git",
|
||||
"zh-CN": "Git",
|
||||
"zh-TW": "Git",
|
||||
"ko-KR": "Git",
|
||||
"no": "Git",
|
||||
"it": "Git",
|
||||
"pt": "Git",
|
||||
"es": "Git",
|
||||
"ar": "Git",
|
||||
"fr": "Git",
|
||||
"tr": "Git",
|
||||
"de": "Git"
|
||||
},
|
||||
"SETTINGS$NAV_APPLICATION": {
|
||||
"en": "Application",
|
||||
"ja": "アプリケーション",
|
||||
"zh-CN": "应用程序",
|
||||
"zh-TW": "應用程式",
|
||||
"ko-KR": "애플리케이션",
|
||||
"no": "Applikasjon",
|
||||
"it": "Applicazione",
|
||||
"pt": "Aplicação",
|
||||
"es": "Aplicación",
|
||||
"ar": "التطبيق",
|
||||
"fr": "Application",
|
||||
"tr": "Uygulama",
|
||||
"de": "Anwendung"
|
||||
},
|
||||
"SETTINGS$NAV_CREDITS": {
|
||||
"en": "Credits",
|
||||
"ja": "クレジット",
|
||||
"zh-CN": "积分",
|
||||
"zh-TW": "點數",
|
||||
"ko-KR": "크레딧",
|
||||
"no": "Kreditter",
|
||||
"it": "Crediti",
|
||||
"pt": "Créditos",
|
||||
"es": "Créditos",
|
||||
"ar": "الرصيد",
|
||||
"fr": "Crédits",
|
||||
"tr": "Krediler",
|
||||
"de": "Guthaben"
|
||||
},
|
||||
"SETTINGS$NAV_API_KEYS": {
|
||||
"en": "API Keys",
|
||||
"ja": "APIキー",
|
||||
"zh-CN": "API密钥",
|
||||
"zh-TW": "API金鑰",
|
||||
"ko-KR": "API 키",
|
||||
"no": "API-nøkler",
|
||||
"it": "Chiavi API",
|
||||
"pt": "Chaves de API",
|
||||
"es": "Claves API",
|
||||
"ar": "مفاتيح API",
|
||||
"fr": "Clés API",
|
||||
"tr": "API Anahtarları",
|
||||
"de": "API-Schlüssel"
|
||||
},
|
||||
"SETTINGS$NAV_LLM": {
|
||||
"en": "LLM",
|
||||
"ja": "LLM",
|
||||
"zh-CN": "LLM",
|
||||
"zh-TW": "LLM",
|
||||
"ko-KR": "LLM",
|
||||
"no": "LLM",
|
||||
"it": "LLM",
|
||||
"pt": "LLM",
|
||||
"es": "LLM",
|
||||
"ar": "LLM",
|
||||
"fr": "LLM",
|
||||
"tr": "LLM",
|
||||
"de": "LLM"
|
||||
},
|
||||
"GIT$MERGE_REQUEST": {
|
||||
"en": "Merge Request",
|
||||
"ja": "マージリクエスト",
|
||||
"zh-CN": "合并请求",
|
||||
"zh-TW": "合併請求",
|
||||
"ko-KR": "머지 요청",
|
||||
"no": "Fletteforespørsel",
|
||||
"it": "Richiesta di fusione",
|
||||
"pt": "Solicitação de mesclagem",
|
||||
"es": "Solicitud de fusión",
|
||||
"ar": "طلب الدمج",
|
||||
"fr": "Demande de fusion",
|
||||
"tr": "Birleştirme İsteği",
|
||||
"de": "Merge-Anfrage"
|
||||
},
|
||||
"GIT$GITLAB_API": {
|
||||
"en": "GitLab API",
|
||||
"ja": "GitLab API",
|
||||
"zh-CN": "GitLab API",
|
||||
"zh-TW": "GitLab API",
|
||||
"ko-KR": "GitLab API",
|
||||
"no": "GitLab API",
|
||||
"it": "API GitLab",
|
||||
"pt": "API do GitLab",
|
||||
"es": "API de GitLab",
|
||||
"ar": "واجهة برمجة تطبيقات GitLab",
|
||||
"fr": "API GitLab",
|
||||
"tr": "GitLab API",
|
||||
"de": "GitLab API"
|
||||
},
|
||||
"GIT$PULL_REQUEST": {
|
||||
"en": "Pull Request",
|
||||
"ja": "プルリクエスト",
|
||||
"zh-CN": "拉取请求",
|
||||
"zh-TW": "拉取請求",
|
||||
"ko-KR": "풀 리퀘스트",
|
||||
"no": "Trekkforespørsel",
|
||||
"it": "Richiesta di pull",
|
||||
"pt": "Solicitação de pull",
|
||||
"es": "Solicitud de extracción",
|
||||
"ar": "طلب السحب",
|
||||
"fr": "Demande de tirage",
|
||||
"tr": "Çekme İsteği",
|
||||
"de": "Pull Request"
|
||||
},
|
||||
"GIT$GITHUB_API": {
|
||||
"en": "GitHub API",
|
||||
"ja": "GitHub API",
|
||||
"zh-CN": "GitHub API",
|
||||
"zh-TW": "GitHub API",
|
||||
"ko-KR": "GitHub API",
|
||||
"no": "GitHub API",
|
||||
"it": "API GitHub",
|
||||
"pt": "API do GitHub",
|
||||
"es": "API de GitHub",
|
||||
"ar": "واجهة برمجة تطبيقات GitHub",
|
||||
"fr": "API GitHub",
|
||||
"tr": "GitHub API",
|
||||
"de": "GitHub API"
|
||||
},
|
||||
"BUTTON$COPY": {
|
||||
"en": "Copy to clipboard",
|
||||
"ja": "クリップボードにコピー",
|
||||
@@ -1434,6 +1599,51 @@
|
||||
"tr": "Jetonunuzu alın",
|
||||
"de": "Token abrufen"
|
||||
},
|
||||
"GITHUB$TOKEN_HELP_TEXT": {
|
||||
"en": "Get your <0>GitHub token</0> or <1>click here for instructions</1>",
|
||||
"ja": "<0>GitHubトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
|
||||
"zh-CN": "获取您的<0>GitHub令牌</0>或<1>点击此处获取说明</1>",
|
||||
"zh-TW": "取得您的<0>GitHub權杖</0>或<1>點擊此處獲取說明</1>",
|
||||
"ko-KR": "<0>GitHub 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
|
||||
"no": "Få din <0>GitHub-token</0> eller <1>klikk her for instruksjoner</1>",
|
||||
"it": "Ottieni il tuo <0>token GitHub</0> o <1>clicca qui per istruzioni</1>",
|
||||
"pt": "Obtenha seu <0>token GitHub</0> ou <1>clique aqui para instruções</1>",
|
||||
"es": "Obtenga su <0>token de GitHub</0> o <1>haga clic aquí para obtener instrucciones</1>",
|
||||
"ar": "احصل على <0>رمز GitHub</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
|
||||
"fr": "Obtenez votre <0>jeton GitHub</0> ou <1>cliquez ici pour les instructions</1>",
|
||||
"tr": "<0>GitHub jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
|
||||
"de": "Holen Sie sich Ihren <0>GitHub-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
|
||||
},
|
||||
"GITHUB$TOKEN_LINK_TEXT": {
|
||||
"en": "GitHub token",
|
||||
"ja": "GitHubトークン",
|
||||
"zh-CN": "GitHub令牌",
|
||||
"zh-TW": "GitHub權杖",
|
||||
"ko-KR": "GitHub 토큰",
|
||||
"no": "GitHub-token",
|
||||
"it": "token GitHub",
|
||||
"pt": "token GitHub",
|
||||
"es": "token de GitHub",
|
||||
"ar": "رمز GitHub",
|
||||
"fr": "jeton GitHub",
|
||||
"tr": "GitHub jetonu",
|
||||
"de": "GitHub-Token"
|
||||
},
|
||||
"GITHUB$INSTRUCTIONS_LINK_TEXT": {
|
||||
"en": "click here for instructions",
|
||||
"ja": "手順についてはここをクリック",
|
||||
"zh-CN": "点击此处获取说明",
|
||||
"zh-TW": "點擊此處獲取說明",
|
||||
"ko-KR": "지침을 보려면 여기를 클릭",
|
||||
"no": "klikk her for instruksjoner",
|
||||
"it": "clicca qui per istruzioni",
|
||||
"pt": "clique aqui para instruções",
|
||||
"es": "haga clic aquí para obtener instrucciones",
|
||||
"ar": "انقر هنا للحصول على تعليمات",
|
||||
"fr": "cliquez ici pour les instructions",
|
||||
"tr": "talimatlar için buraya tıklayın",
|
||||
"de": "klicken Sie hier für Anweisungen"
|
||||
},
|
||||
"COMMON$HERE": {
|
||||
"en": "here",
|
||||
"ja": "こちら",
|
||||
@@ -6274,6 +6484,51 @@
|
||||
"tr": "Üzerinde bir jeton oluştur",
|
||||
"de": "Token generieren auf"
|
||||
},
|
||||
"GITLAB$TOKEN_HELP_TEXT": {
|
||||
"en": "Get your <0>GitLab token</0> or <1>click here for instructions</1>",
|
||||
"ja": "<0>GitLabトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
|
||||
"zh-CN": "获取您的<0>GitLab令牌</0>或<1>点击此处获取说明</1>",
|
||||
"zh-TW": "取得您的<0>GitLab權杖</0>或<1>點擊此處獲取說明</1>",
|
||||
"ko-KR": "<0>GitLab 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
|
||||
"no": "Få din <0>GitLab-token</0> eller <1>klikk her for instruksjoner</1>",
|
||||
"it": "Ottieni il tuo <0>token GitLab</0> o <1>clicca qui per istruzioni</1>",
|
||||
"pt": "Obtenha seu <0>token GitLab</0> ou <1>clique aqui para instruções</1>",
|
||||
"es": "Obtenga su <0>token de GitLab</0> o <1>haga clic aquí para obtener instrucciones</1>",
|
||||
"ar": "احصل على <0>رمز GitLab</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
|
||||
"fr": "Obtenez votre <0>jeton GitLab</0> ou <1>cliquez ici pour les instructions</1>",
|
||||
"tr": "<0>GitLab jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
|
||||
"de": "Holen Sie sich Ihren <0>GitLab-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
|
||||
},
|
||||
"GITLAB$TOKEN_LINK_TEXT": {
|
||||
"en": "GitLab token",
|
||||
"ja": "GitLabトークン",
|
||||
"zh-CN": "GitLab令牌",
|
||||
"zh-TW": "GitLab權杖",
|
||||
"ko-KR": "GitLab 토큰",
|
||||
"no": "GitLab-token",
|
||||
"it": "token GitLab",
|
||||
"pt": "token GitLab",
|
||||
"es": "token de GitLab",
|
||||
"ar": "رمز GitLab",
|
||||
"fr": "jeton GitLab",
|
||||
"tr": "GitLab jetonu",
|
||||
"de": "GitLab-Token"
|
||||
},
|
||||
"GITLAB$INSTRUCTIONS_LINK_TEXT": {
|
||||
"en": "click here for instructions",
|
||||
"ja": "手順についてはここをクリック",
|
||||
"zh-CN": "点击此处获取说明",
|
||||
"zh-TW": "點擊此處獲取說明",
|
||||
"ko-KR": "지침을 보려면 여기를 클릭",
|
||||
"no": "klikk her for instruksjoner",
|
||||
"it": "clicca qui per istruzioni",
|
||||
"pt": "clique aqui para instruções",
|
||||
"es": "haga clic aquí para obtener instrucciones",
|
||||
"ar": "انقر هنا للحصول على تعليمات",
|
||||
"fr": "cliquez ici pour les instructions",
|
||||
"tr": "talimatlar için buraya tıklayın",
|
||||
"de": "klicken Sie hier für Anweisungen"
|
||||
},
|
||||
"GITLAB$OR_SEE": {
|
||||
"en": "or see the",
|
||||
"ja": "または参照",
|
||||
@@ -6304,6 +6559,21 @@
|
||||
"tr": "belgelendirme",
|
||||
"de": "Dokumentation"
|
||||
},
|
||||
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED": {
|
||||
"en": "The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
|
||||
"ja": "アクションは実行されていません。これはユーザーが停止ボタンを押したか、リソース制約によりランタイムシステムがクラッシュして再起動したことが原因かもしれません。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
|
||||
"zh-CN": "该操作尚未执行。这可能是因为用户按下了停止按钮,或者因为运行时系统由于资源限制而崩溃并重新启动。任何先前建立的系统状态、依赖项或环境变量可能已丢失。",
|
||||
"zh-TW": "該操作尚未執行。這可能是因為用戶按下了停止按鈕,或者因為運行時系統由於資源限制而崩潰並重新啟動。任何先前建立的系統狀態、依賴項或環境變數可能已丟失。",
|
||||
"ko-KR": "작업이 실행되지 않았습니다. 이는 사용자가 중지 버튼을 눌렀거나 리소스 제약으로 인해 런타임 시스템이 충돌하고 재시작되었기 때문일 수 있습니다. 이전에 설정된 시스템 상태, 종속성 또는 환경 변수가 손실되었을 수 있습니다.",
|
||||
"no": "Handlingen har ikke blitt utført. Dette kan ha skjedd fordi brukeren trykket på stoppknappen, eller fordi kjøretidssystemet krasjet og startet på nytt på grunn av ressursbegrensninger. Enhver tidligere etablert systemtilstand, avhengigheter eller miljøvariabler kan ha gått tapt.",
|
||||
"it": "L'azione non è stata eseguita. Ciò potrebbe essere accaduto perché l'utente ha premuto il pulsante di arresto, o perché il sistema di runtime si è arrestato in modo anomalo e riavviato a causa di vincoli di risorse. Qualsiasi stato di sistema, dipendenza o variabile d'ambiente precedentemente stabilito potrebbe essere andato perso.",
|
||||
"pt": "A ação não foi executada. Isso pode ter ocorrido porque o usuário pressionou o botão de parar, ou porque o sistema de tempo de execução travou e reiniciou devido a restrições de recursos. Qualquer estado do sistema, dependências ou variáveis de ambiente estabelecidos anteriormente podem ter sido perdidos.",
|
||||
"es": "La acción no se ha ejecutado. Esto puede haber ocurrido porque el usuario presionó el botón de detener, o porque el sistema de tiempo de ejecución se bloqueó y reinició debido a restricciones de recursos. Cualquier estado del sistema, dependencias o variables de entorno establecidos previamente pueden haberse perdido.",
|
||||
"ar": "لم يتم تنفيذ الإجراء. قد يكون هذا حدث لأن المستخدم ضغط على زر التوقف، أو لأن نظام التشغيل تعطل وأعيد تشغيله بسبب قيود الموارد. قد تكون أي حالة نظام أو تبعيات أو متغيرات بيئية تم إنشاؤها مسبقًا قد فُقدت.",
|
||||
"fr": "L'action n'a pas été exécutée. Cela peut s'être produit parce que l'utilisateur a appuyé sur le bouton d'arrêt, ou parce que le système d'exécution s'est planté et a redémarré en raison de contraintes de ressources. Tout état du système, dépendances ou variables d'environnement précédemment établis peuvent avoir été perdus.",
|
||||
"tr": "Eylem yürütülmedi. Bu, kullanıcının durdurma düğmesine basması veya çalışma zamanı sisteminin kaynak kısıtlamaları nedeniyle çökmesi ve yeniden başlaması nedeniyle olmuş olabilir. Daha önce kurulmuş olan herhangi bir sistem durumu, bağımlılıklar veya ortam değişkenleri kaybolmuş olabilir.",
|
||||
"de": "Die Aktion wurde nicht ausgeführt. Dies kann passiert sein, weil der Benutzer die Stopp-Taste gedrückt hat oder weil das Laufzeitsystem aufgrund von Ressourcenbeschränkungen abgestürzt und neu gestartet wurde. Alle zuvor eingerichteten Systemzustände, Abhängigkeiten oder Umgebungsvariablen sind möglicherweise verloren gegangen."
|
||||
},
|
||||
"DIFF_VIEWER$LOADING": {
|
||||
"en": "Loading...",
|
||||
"ja": "読み込み中...",
|
||||
@@ -6499,4 +6769,53 @@
|
||||
"es": "No hay herramientas disponibles para este agente",
|
||||
"tr": "Bu ajan için kullanılabilir araç yok"
|
||||
}
|
||||
,
|
||||
"TOS$ACCEPT_TERMS_OF_SERVICE": {
|
||||
"en": "Accept Terms of Service",
|
||||
"ja": "利用規約に同意する",
|
||||
"zh-CN": "接受服务条款",
|
||||
"zh-TW": "接受服務條款",
|
||||
"ko-KR": "서비스 약관 동의",
|
||||
"fr": "Accepter les conditions d'utilisation",
|
||||
"es": "Aceptar términos de servicio",
|
||||
"de": "Nutzungsbedingungen akzeptieren",
|
||||
"it": "Accetta i termini di servizio",
|
||||
"pt": "Aceitar termos de serviço"
|
||||
},
|
||||
"TOS$ACCEPT_TERMS_DESCRIPTION": {
|
||||
"en": "Please review and accept our terms of service before continuing",
|
||||
"ja": "続行する前に利用規約を確認して同意してください",
|
||||
"zh-CN": "请在继续之前查看并接受我们的服务条款",
|
||||
"zh-TW": "請在繼續之前查看並接受我們的服務條款",
|
||||
"ko-KR": "계속하기 전에 서비스 약관을 검토하고 동의해 주세요",
|
||||
"fr": "Veuillez examiner et accepter nos conditions d'utilisation avant de continuer",
|
||||
"es": "Por favor, revise y acepte nuestros términos de servicio antes de continuar",
|
||||
"de": "Bitte überprüfen und akzeptieren Sie unsere Nutzungsbedingungen, bevor Sie fortfahren",
|
||||
"it": "Si prega di rivedere e accettare i nostri termini di servizio prima di continuare",
|
||||
"pt": "Por favor, revise e aceite nossos termos de serviço antes de continuar"
|
||||
},
|
||||
"TOS$CONTINUE": {
|
||||
"en": "Continue",
|
||||
"ja": "続行",
|
||||
"zh-CN": "继续",
|
||||
"zh-TW": "繼續",
|
||||
"ko-KR": "계속",
|
||||
"fr": "Continuer",
|
||||
"es": "Continuar",
|
||||
"de": "Fortfahren",
|
||||
"it": "Continua",
|
||||
"pt": "Continuar"
|
||||
},
|
||||
"TOS$ERROR_ACCEPTING": {
|
||||
"en": "Error accepting Terms of Service",
|
||||
"ja": "利用規約の承諾中にエラーが発生しました",
|
||||
"zh-CN": "接受服务条款时出错",
|
||||
"zh-TW": "接受服務條款時出錯",
|
||||
"ko-KR": "서비스 약관 수락 중 오류 발생",
|
||||
"fr": "Erreur lors de l'acceptation des conditions d'utilisation",
|
||||
"es": "Error al aceptar los Términos de Servicio",
|
||||
"de": "Fehler beim Akzeptieren der Nutzungsbedingungen",
|
||||
"it": "Errore nell'accettazione dei Termini di Servizio",
|
||||
"pt": "Erro ao aceitar os Termos de Serviço"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@ const MOCK_USER_PREFERENCES: {
|
||||
settings: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the user settings to the default settings
|
||||
*
|
||||
* Useful for resetting the settings in tests
|
||||
*/
|
||||
export const resetTestHandlersMockSettings = () => {
|
||||
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
|
||||
};
|
||||
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
@@ -80,6 +89,7 @@ const openHandsHandlers = [
|
||||
HttpResponse.json([
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"anthropic/claude-3.5",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
]),
|
||||
@@ -173,6 +183,7 @@ export const handlers = [
|
||||
return HttpResponse.json(settings);
|
||||
}),
|
||||
http.post("/api/settings", async ({ request }) => {
|
||||
await delay();
|
||||
const body = await request.json();
|
||||
|
||||
if (body) {
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
export default [
|
||||
layout("routes/root-layout.tsx", [
|
||||
index("routes/home.tsx"),
|
||||
route("accept-tos", "routes/accept-tos.tsx"),
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/account-settings.tsx"),
|
||||
index("routes/llm-settings.tsx"),
|
||||
route("git", "routes/git-settings.tsx"),
|
||||
route("app", "routes/app-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
route("api-keys", "routes/api-keys.tsx"),
|
||||
]),
|
||||
|
||||
84
frontend/src/routes/accept-tos.tsx
Normal file
84
frontend/src/routes/accept-tos.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import { TOSCheckbox } from "#/components/features/waitlist/tos-checkbox";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
|
||||
export default function AcceptTOS() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||
|
||||
// Get the redirect URL from the query parameters
|
||||
const redirectUrl = searchParams.get("redirect_url") || "/";
|
||||
|
||||
// Use mutation for accepting TOS
|
||||
const { mutate: acceptTOS, isPending: isSubmitting } = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Set consent for analytics
|
||||
handleCaptureConsent(true);
|
||||
|
||||
// Call the API to record TOS acceptance in the database
|
||||
return openHands.post("/api/accept_tos", {
|
||||
redirect_url: redirectUrl,
|
||||
});
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
// Get the redirect URL from the response
|
||||
const finalRedirectUrl = response.data.redirect_url || redirectUrl;
|
||||
|
||||
// Check if the redirect URL is an external URL (starts with http or https)
|
||||
if (
|
||||
finalRedirectUrl.startsWith("http://") ||
|
||||
finalRedirectUrl.startsWith("https://")
|
||||
) {
|
||||
// For external URLs, redirect using window.location
|
||||
window.location.href = finalRedirectUrl;
|
||||
} else {
|
||||
// For internal routes, use navigate
|
||||
navigate(finalRedirectUrl);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleAcceptTOS = () => {
|
||||
if (isTosAccepted && !isSubmitting) {
|
||||
acceptTOS();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="border border-tertiary p-8 rounded-lg max-w-md w-full flex flex-col gap-6 items-center bg-base-secondary">
|
||||
<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.TOS$ACCEPT_TERMS_OF_SERVICE)}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t(I18nKey.TOS$ACCEPT_TERMS_DESCRIPTION)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
|
||||
|
||||
<BrandButton
|
||||
isDisabled={!isTosAccepted || isSubmitting}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleAcceptTOS}
|
||||
className="w-full"
|
||||
>
|
||||
{isSubmitting ? t(I18nKey.HOME$LOADING) : t(I18nKey.TOS$CONTINUE)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { HelpLink } from "#/components/features/settings/help-link";
|
||||
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useAppLogout } from "#/hooks/use-app-logout";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
|
||||
import { isCustomModel } from "#/utils/is-custom-model";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { ProviderOptions } from "#/types/settings";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
// Define REMOTE_RUNTIME_OPTIONS for testing
|
||||
const REMOTE_RUNTIME_OPTIONS = [
|
||||
{ key: "1", label: "Standard" },
|
||||
{ key: "2", label: "Enhanced" },
|
||||
{ key: "4", label: "Premium" },
|
||||
];
|
||||
|
||||
function AccountSettings() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: settings,
|
||||
isFetching: isFetchingSettings,
|
||||
isFetched,
|
||||
isSuccess: isSuccessfulSettings,
|
||||
} = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
const {
|
||||
data: resources,
|
||||
isFetching: isFetchingResources,
|
||||
isSuccess: isSuccessfulResources,
|
||||
} = useAIConfigOptions();
|
||||
const { mutate: saveSettings } = useSaveSettings();
|
||||
const { handleLogout } = useAppLogout();
|
||||
const { providerTokensSet, providersAreSet } = useAuth();
|
||||
|
||||
const isFetching = isFetchingSettings || isFetchingResources;
|
||||
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const shouldHandleSpecialSaasCase =
|
||||
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && isSaas;
|
||||
|
||||
const determineWhetherToToggleAdvancedSettings = () => {
|
||||
if (shouldHandleSpecialSaasCase) return true;
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
isCustomModel(resources.models, settings.LLM_MODEL) ||
|
||||
hasAdvancedSettingsSet({
|
||||
...settings,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasAppSlug = !!config?.APP_SLUG;
|
||||
const isGitHubTokenSet =
|
||||
providerTokensSet.includes(ProviderOptions.github) || false;
|
||||
const isGitLabTokenSet =
|
||||
providerTokensSet.includes(ProviderOptions.gitlab) || false;
|
||||
const isLLMKeySet = settings?.LLM_API_KEY_SET;
|
||||
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
|
||||
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
|
||||
|
||||
const modelsAndProviders = organizeModelsAndProviders(
|
||||
resources?.models || [],
|
||||
);
|
||||
|
||||
const [llmConfigMode, setLlmConfigMode] = React.useState<
|
||||
"basic" | "advanced"
|
||||
>(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
|
||||
React.useState(!!settings?.SECURITY_ANALYZER);
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const onSubmit = async (formData: FormData) => {
|
||||
const languageLabel = formData.get("language-input")?.toString();
|
||||
const languageValue = AvailableLanguages.find(
|
||||
({ label }) => label === languageLabel,
|
||||
)?.value;
|
||||
|
||||
const llmProvider = formData.get("llm-provider-input")?.toString();
|
||||
const llmModel = formData.get("llm-model-input")?.toString();
|
||||
const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase();
|
||||
const customLlmModel = formData.get("llm-custom-model-input")?.toString();
|
||||
|
||||
const rawRemoteRuntimeResourceFactor = formData
|
||||
.get("runtime-settings-input")
|
||||
?.toString();
|
||||
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
|
||||
({ label }) => label === rawRemoteRuntimeResourceFactor,
|
||||
)?.key;
|
||||
|
||||
const userConsentsToAnalytics =
|
||||
formData.get("enable-analytics-switch")?.toString() === "on";
|
||||
const enableMemoryCondenser =
|
||||
formData.get("enable-memory-condenser-switch")?.toString() === "on";
|
||||
const enableSoundNotifications =
|
||||
formData.get("enable-sound-notifications-switch")?.toString() === "on";
|
||||
const llmBaseUrl = formData.get("base-url-input")?.toString().trim() || "";
|
||||
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
|
||||
const llmApiKey =
|
||||
inputApiKey === "" && isLLMKeySet
|
||||
? undefined // don't update if it's already set and input is empty
|
||||
: inputApiKey; // otherwise use the input value
|
||||
|
||||
const githubToken = formData.get("github-token-input")?.toString();
|
||||
const gitlabToken = formData.get("gitlab-token-input")?.toString();
|
||||
// we don't want the user to be able to modify these settings in SaaS
|
||||
const finalLlmModel = shouldHandleSpecialSaasCase
|
||||
? undefined
|
||||
: customLlmModel || fullLlmModel;
|
||||
const finalLlmBaseUrl = shouldHandleSpecialSaasCase
|
||||
? undefined
|
||||
: llmBaseUrl;
|
||||
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
|
||||
|
||||
const newSettings = {
|
||||
provider_tokens:
|
||||
githubToken || gitlabToken
|
||||
? {
|
||||
github: githubToken || "",
|
||||
gitlab: gitlabToken || "",
|
||||
}
|
||||
: undefined,
|
||||
LANGUAGE: languageValue,
|
||||
user_consents_to_analytics: userConsentsToAnalytics,
|
||||
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
|
||||
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
|
||||
LLM_MODEL: finalLlmModel,
|
||||
LLM_BASE_URL: finalLlmBaseUrl,
|
||||
llm_api_key: finalLlmApiKey,
|
||||
AGENT: formData.get("agent-input")?.toString(),
|
||||
SECURITY_ANALYZER:
|
||||
formData.get("security-analyzer-input")?.toString() || "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
remoteRuntimeResourceFactor !== null
|
||||
? Number(remoteRuntimeResourceFactor)
|
||||
: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
CONFIRMATION_MODE: confirmationModeIsEnabled,
|
||||
};
|
||||
|
||||
saveSettings(newSettings, {
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(userConsentsToAnalytics);
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// If settings is still loading by the time the state is set, it will always
|
||||
// default to basic settings. This is a workaround to ensure the correct
|
||||
// settings are displayed.
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
}, [isAdvancedSettingsSet]);
|
||||
|
||||
if (isFetched && !settings) {
|
||||
return <div>Failed to fetch settings. Please try reloading.</div>;
|
||||
}
|
||||
|
||||
const onToggleAdvancedMode = (isToggled: boolean) => {
|
||||
setLlmConfigMode(isToggled ? "advanced" : "basic");
|
||||
if (!isToggled) {
|
||||
// reset advanced state
|
||||
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching || !settings) {
|
||||
return (
|
||||
<div className="flex grow p-4">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
data-testid="account-settings-form"
|
||||
ref={formRef}
|
||||
action={onSubmit}
|
||||
className="flex flex-col grow overflow-auto"
|
||||
>
|
||||
<div className="flex flex-col gap-12 px-11 py-9">
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<section
|
||||
data-testid="llm-settings-section"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-7">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
{t(I18nKey.SETTINGS$LLM_SETTINGS)}
|
||||
</h2>
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={isAdvancedSettingsSet}
|
||||
onToggle={onToggleAdvancedMode}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{llmConfigMode === "basic" && !shouldHandleSpecialSaasCase && (
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={settings.LLM_MODEL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
|
||||
<SettingsInput
|
||||
testId="llm-custom-model-input"
|
||||
name="llm-custom-model-input"
|
||||
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
|
||||
defaultValue={settings.LLM_MODEL}
|
||||
placeholder="anthropic/claude-3-5-sonnet-20241022"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
name="base-url-input"
|
||||
label={t(I18nKey.SETTINGS$BASE_URL)}
|
||||
defaultValue={settings.LLM_BASE_URL}
|
||||
placeholder="https://api.openai.com"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={isLLMKeySet ? "<hidden>" : ""}
|
||||
startContent={
|
||||
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="agent-input"
|
||||
name="agent-input"
|
||||
label={t(I18nKey.SETTINGS$AGENT)}
|
||||
items={
|
||||
resources?.agents.map((agent) => ({
|
||||
key: agent,
|
||||
label: agent,
|
||||
})) || []
|
||||
}
|
||||
wrapperClassName="w-[680px]"
|
||||
defaultSelectedKey={settings.AGENT}
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSaas && llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="runtime-settings-input"
|
||||
name="runtime-settings-input"
|
||||
label={
|
||||
<>
|
||||
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
|
||||
<a href="mailto:contact@all-hands.dev">
|
||||
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
}
|
||||
items={REMOTE_RUNTIME_OPTIONS}
|
||||
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
|
||||
isDisabled
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsSwitch
|
||||
testId="enable-confirmation-mode-switch"
|
||||
onToggle={setConfirmationModeIsEnabled}
|
||||
defaultIsToggled={!!settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsSwitch
|
||||
testId="enable-memory-condenser-switch"
|
||||
name="enable-memory-condenser-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_DEFAULT_CONDENSER}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
|
||||
<div>
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security-analyzer-input"
|
||||
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
|
||||
items={
|
||||
resources?.securityAnalyzers.map((analyzer) => ({
|
||||
key: analyzer,
|
||||
label: analyzer,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.SECURITY_ANALYZER}
|
||||
isClearable
|
||||
showOptionalTag
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
|
||||
</h2>
|
||||
{isSaas && hasAppSlug && (
|
||||
<Link
|
||||
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</BrandButton>
|
||||
</Link>
|
||||
)}
|
||||
{!isSaas && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="github-token-input"
|
||||
name="github-token-input"
|
||||
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
isGitHubTokenSet && (
|
||||
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
|
||||
)
|
||||
}
|
||||
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
|
||||
/>
|
||||
<p data-testid="github-token-help-anchor" className="text-xs">
|
||||
{" "}
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<b>
|
||||
{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>{" "}
|
||||
</b>
|
||||
{t(I18nKey.COMMON$HERE)}{" "}
|
||||
<b>
|
||||
<a
|
||||
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
|
||||
</a>
|
||||
</b>
|
||||
.
|
||||
</p>
|
||||
|
||||
<SettingsInput
|
||||
testId="gitlab-token-input"
|
||||
name="gitlab-token-input"
|
||||
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
isGitLabTokenSet && (
|
||||
<KeyStatusIcon isSet={!!isGitLabTokenSet} />
|
||||
)
|
||||
}
|
||||
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
|
||||
/>
|
||||
|
||||
<p data-testid="gitlab-token-help-anchor" className="text-xs">
|
||||
{" "}
|
||||
{t(I18nKey.GITLAB$GET_TOKEN)}{" "}
|
||||
<b>
|
||||
{" "}
|
||||
<a
|
||||
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitLab
|
||||
</a>{" "}
|
||||
</b>
|
||||
{t(I18nKey.GITLAB$OR_SEE)}{" "}
|
||||
<b>
|
||||
<a
|
||||
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(I18nKey.COMMON$DOCUMENTATION)}
|
||||
</a>
|
||||
</b>
|
||||
.
|
||||
</p>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleLogout}
|
||||
isDisabled={!providersAreSet}
|
||||
>
|
||||
Disconnect Tokens
|
||||
</BrandButton>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS)}
|
||||
</h2>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="language-input"
|
||||
name="language-input"
|
||||
label={t(I18nKey.SETTINGS$LANGUAGE)}
|
||||
items={AvailableLanguages.map((language) => ({
|
||||
key: language.value,
|
||||
label: language.label,
|
||||
}))}
|
||||
defaultSelectedKey={settings.LANGUAGE}
|
||||
wrapperClassName="w-[680px]"
|
||||
isClearable={false}
|
||||
/>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-analytics-switch"
|
||||
name="enable-analytics-switch"
|
||||
defaultIsToggled={!!isAnalyticsEnabled}
|
||||
>
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
</SettingsSwitch>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-sound-notifications-switch"
|
||||
name="enable-sound-notifications-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
|
||||
</SettingsSwitch>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
formRef.current?.requestSubmit();
|
||||
}}
|
||||
>
|
||||
{t(I18nKey.BUTTON$SAVE)}
|
||||
</BrandButton>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountSettings;
|
||||
@@ -3,7 +3,7 @@ import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"
|
||||
|
||||
function ApiKeysScreen() {
|
||||
return (
|
||||
<div className="flex flex-col grow overflow-auto p-11">
|
||||
<div className="flex flex-col grow overflow-auto p-9">
|
||||
<ApiKeysManager />
|
||||
</div>
|
||||
);
|
||||
|
||||
150
frontend/src/routes/app-settings.tsx
Normal file
150
frontend/src/routes/app-settings.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
|
||||
|
||||
function AppSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { mutate: saveSettings, isPending } = useSaveSettings();
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
|
||||
const [languageInputHasChanged, setLanguageInputHasChanged] =
|
||||
React.useState(false);
|
||||
const [analyticsSwitchHasChanged, setAnalyticsSwitchHasChanged] =
|
||||
React.useState(false);
|
||||
const [
|
||||
soundNotificationsSwitchHasChanged,
|
||||
setSoundNotificationsSwitchHasChanged,
|
||||
] = React.useState(false);
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const languageLabel = formData.get("language-input")?.toString();
|
||||
const languageValue = AvailableLanguages.find(
|
||||
({ label }) => label === languageLabel,
|
||||
)?.value;
|
||||
const language = languageValue || DEFAULT_SETTINGS.LANGUAGE;
|
||||
|
||||
const enableAnalytics =
|
||||
formData.get("enable-analytics-switch")?.toString() === "on";
|
||||
const enableSoundNotifications =
|
||||
formData.get("enable-sound-notifications-switch")?.toString() === "on";
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
LANGUAGE: language,
|
||||
user_consents_to_analytics: enableAnalytics,
|
||||
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(enableAnalytics);
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
onSettled: () => {
|
||||
setLanguageInputHasChanged(false);
|
||||
setAnalyticsSwitchHasChanged(false);
|
||||
setSoundNotificationsSwitchHasChanged(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const checkIfLanguageInputHasChanged = (value: string) => {
|
||||
const selectedLanguage = AvailableLanguages.find(
|
||||
({ label: langValue }) => langValue === value,
|
||||
)?.label;
|
||||
const currentLanguage = AvailableLanguages.find(
|
||||
({ value: langValue }) => langValue === settings?.LANGUAGE,
|
||||
)?.label;
|
||||
|
||||
setLanguageInputHasChanged(selectedLanguage !== currentLanguage);
|
||||
};
|
||||
|
||||
const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => {
|
||||
const currentAnalytics = !!settings?.USER_CONSENTS_TO_ANALYTICS;
|
||||
setAnalyticsSwitchHasChanged(checked !== currentAnalytics);
|
||||
};
|
||||
|
||||
const checkIfSoundNotificationsSwitchHasChanged = (checked: boolean) => {
|
||||
const currentSoundNotifications = !!settings?.ENABLE_SOUND_NOTIFICATIONS;
|
||||
setSoundNotificationsSwitchHasChanged(
|
||||
checked !== currentSoundNotifications,
|
||||
);
|
||||
};
|
||||
|
||||
const formIsClean =
|
||||
!languageInputHasChanged &&
|
||||
!analyticsSwitchHasChanged &&
|
||||
!soundNotificationsSwitchHasChanged;
|
||||
|
||||
const shouldBeLoading = !settings || isLoading || isPending;
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid="app-settings-screen"
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
{shouldBeLoading && <AppSettingsInputsSkeleton />}
|
||||
{!shouldBeLoading && (
|
||||
<div className="p-9 flex flex-col gap-6">
|
||||
<LanguageInput
|
||||
name="language-input"
|
||||
defaultKey={settings.LANGUAGE}
|
||||
onChange={checkIfLanguageInputHasChanged}
|
||||
/>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-analytics-switch"
|
||||
name="enable-analytics-switch"
|
||||
defaultIsToggled={!!settings.USER_CONSENTS_TO_ANALYTICS}
|
||||
onToggle={checkIfAnalyticsSwitchHasChanged}
|
||||
>
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
</SettingsSwitch>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-sound-notifications-switch"
|
||||
name="enable-sound-notifications-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
|
||||
onToggle={checkIfSoundNotificationsSwitchHasChanged}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
|
||||
</SettingsSwitch>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isDisabled={isPending || formIsClean}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppSettingsScreen;
|
||||
134
frontend/src/routes/git-settings.tsx
Normal file
134
frontend/src/routes/git-settings.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
|
||||
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
|
||||
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
|
||||
|
||||
function GitSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { mutate: saveSettings, isPending } = useSaveSettings();
|
||||
const { mutate: disconnectGitTokens } = useLogout();
|
||||
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const [githubTokenInputHasValue, setGithubTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const isGitHubTokenSet = !!settings?.PROVIDER_TOKENS_SET.github;
|
||||
const isGitLabTokenSet = !!settings?.PROVIDER_TOKENS_SET.gitlab;
|
||||
|
||||
const formAction = async (formData: FormData) => {
|
||||
const disconnectButtonClicked =
|
||||
formData.get("disconnect-tokens-button") !== null;
|
||||
|
||||
if (disconnectButtonClicked) {
|
||||
disconnectGitTokens();
|
||||
return;
|
||||
}
|
||||
|
||||
const githubToken = formData.get("github-token-input")?.toString() || "";
|
||||
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
provider_tokens: {
|
||||
github: githubToken,
|
||||
gitlab: gitlabToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
onSettled: () => {
|
||||
setGithubTokenInputHasValue(false);
|
||||
setGitlabTokenInputHasValue(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const formIsClean = !githubTokenInputHasValue && !gitlabTokenInputHasValue;
|
||||
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid="git-settings-screen"
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
{isLoading && <GitSettingInputsSkeleton />}
|
||||
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
|
||||
)}
|
||||
|
||||
{!isSaas && !isLoading && (
|
||||
<div className="p-9 flex flex-col gap-12">
|
||||
<GitHubTokenInput
|
||||
name="github-token-input"
|
||||
isGitHubTokenSet={isGitHubTokenSet}
|
||||
onChange={(value) => {
|
||||
setGithubTokenInputHasValue(!!value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<GitLabTokenInput
|
||||
name="gitlab-token-input"
|
||||
isGitLabTokenSet={isGitLabTokenSet}
|
||||
onChange={(value) => {
|
||||
setGitlabTokenInputHasValue(!!value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!shouldRenderExternalConfigureButtons && (
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
testId="disconnect-tokens-button"
|
||||
name="disconnect-tokens-button"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
|
||||
>
|
||||
Disconnect Tokens
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={isPending || formIsClean}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default GitSettingsScreen;
|
||||
@@ -22,7 +22,7 @@ function HomeScreen() {
|
||||
|
||||
<hr className="border-[#717888]" />
|
||||
|
||||
<main className="flex justify-between gap-4">
|
||||
<main className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<RepoConnector
|
||||
onRepoSelection={(title) => setSelectedRepoTitle(title)}
|
||||
/>
|
||||
|
||||
430
frontend/src/routes/llm-settings.tsx
Normal file
430
frontend/src/routes/llm-settings.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AxiosError } from "axios";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { HelpLink } from "#/components/features/settings/help-link";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { isCustomModel } from "#/utils/is-custom-model";
|
||||
import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton";
|
||||
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
|
||||
function LlmSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { mutate: saveSettings, isPending } = useSaveSettings();
|
||||
|
||||
const { data: resources } = useAIConfigOptions();
|
||||
const { data: settings, isLoading, isFetching } = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const [view, setView] = React.useState<"basic" | "advanced">("basic");
|
||||
const [securityAnalyzerInputIsVisible, setSecurityAnalyzerInputIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
const [dirtyInputs, setDirtyInputs] = React.useState({
|
||||
model: false,
|
||||
apiKey: false,
|
||||
baseUrl: false,
|
||||
agent: false,
|
||||
confirmationMode: false,
|
||||
enableDefaultCondenser: false,
|
||||
securityAnalyzer: false,
|
||||
});
|
||||
|
||||
const modelsAndProviders = organizeModelsAndProviders(
|
||||
resources?.models || [],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const determineWhetherToToggleAdvancedSettings = () => {
|
||||
if (resources && settings) {
|
||||
return (
|
||||
isCustomModel(resources.models, settings.LLM_MODEL) ||
|
||||
hasAdvancedSettingsSet({
|
||||
...settings,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const userSettingsIsAdvanced = determineWhetherToToggleAdvancedSettings();
|
||||
if (settings) setSecurityAnalyzerInputIsVisible(settings.CONFIRMATION_MODE);
|
||||
|
||||
if (userSettingsIsAdvanced) setView("advanced");
|
||||
else setView("basic");
|
||||
}, [settings, resources]);
|
||||
|
||||
const handleSuccessfulMutation = () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
setDirtyInputs({
|
||||
model: false,
|
||||
apiKey: false,
|
||||
baseUrl: false,
|
||||
agent: false,
|
||||
confirmationMode: false,
|
||||
enableDefaultCondenser: false,
|
||||
securityAnalyzer: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleErrorMutation = (error: AxiosError) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
};
|
||||
|
||||
const basicFormAction = (formData: FormData) => {
|
||||
const provider = formData.get("llm-provider-input")?.toString();
|
||||
const model = formData.get("llm-model-input")?.toString();
|
||||
const apiKey = formData.get("llm-api-key-input")?.toString();
|
||||
|
||||
const fullLlmModel =
|
||||
provider && model && `${provider}/${model}`.toLowerCase();
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
LLM_MODEL: fullLlmModel,
|
||||
llm_api_key: apiKey || null,
|
||||
},
|
||||
{
|
||||
onSuccess: handleSuccessfulMutation,
|
||||
onError: handleErrorMutation,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const advancedFormAction = (formData: FormData) => {
|
||||
const model = formData.get("llm-custom-model-input")?.toString();
|
||||
const baseUrl = formData.get("base-url-input")?.toString();
|
||||
const apiKey = formData.get("llm-api-key-input")?.toString();
|
||||
const agent = formData.get("agent-input")?.toString();
|
||||
const confirmationMode =
|
||||
formData.get("enable-confirmation-mode-switch")?.toString() === "on";
|
||||
const enableDefaultCondenser =
|
||||
formData.get("enable-memory-condenser-switch")?.toString() === "on";
|
||||
const securityAnalyzer = formData
|
||||
.get("security-analyzer-input")
|
||||
?.toString();
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
LLM_MODEL: model,
|
||||
LLM_BASE_URL: baseUrl,
|
||||
llm_api_key: apiKey,
|
||||
AGENT: agent,
|
||||
CONFIRMATION_MODE: confirmationMode,
|
||||
ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser,
|
||||
SECURITY_ANALYZER: confirmationMode ? securityAnalyzer : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: handleSuccessfulMutation,
|
||||
onError: handleErrorMutation,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
if (view === "basic") basicFormAction(formData);
|
||||
else advancedFormAction(formData);
|
||||
};
|
||||
|
||||
const handleToggleAdvancedSettings = (isToggled: boolean) => {
|
||||
setSecurityAnalyzerInputIsVisible(!!settings?.CONFIRMATION_MODE);
|
||||
setView(isToggled ? "advanced" : "basic");
|
||||
setDirtyInputs({
|
||||
model: false,
|
||||
apiKey: false,
|
||||
baseUrl: false,
|
||||
agent: false,
|
||||
confirmationMode: false,
|
||||
enableDefaultCondenser: false,
|
||||
securityAnalyzer: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelIsDirty = (model: string | null) => {
|
||||
// openai providers are special case; see ModelSelector
|
||||
// component for details
|
||||
const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", "");
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
model: modelIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApiKeyIsDirty = (apiKey: string) => {
|
||||
const apiKeyIsDirty = apiKey !== "";
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
apiKey: apiKeyIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCustomModelIsDirty = (model: string) => {
|
||||
const modelIsDirty = model !== settings?.LLM_MODEL && model !== "";
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
model: modelIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBaseUrlIsDirty = (baseUrl: string) => {
|
||||
const baseUrlIsDirty = baseUrl !== settings?.LLM_BASE_URL;
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
baseUrl: baseUrlIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAgentIsDirty = (agent: string) => {
|
||||
const agentIsDirty = agent !== settings?.AGENT && agent !== "";
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
agent: agentIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleConfirmationModeIsDirty = (isToggled: boolean) => {
|
||||
setSecurityAnalyzerInputIsVisible(isToggled);
|
||||
const confirmationModeIsDirty = isToggled !== settings?.CONFIRMATION_MODE;
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
confirmationMode: confirmationModeIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => {
|
||||
const enableDefaultCondenserIsDirty =
|
||||
isToggled !== settings?.ENABLE_DEFAULT_CONDENSER;
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
enableDefaultCondenser: enableDefaultCondenserIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => {
|
||||
const securityAnalyzerIsDirty =
|
||||
securityAnalyzer !== settings?.SECURITY_ANALYZER;
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
securityAnalyzer: securityAnalyzerIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const formIsDirty = Object.values(dirtyInputs).some((isDirty) => isDirty);
|
||||
|
||||
if (!settings || isFetching) return <LlmSettingsInputsSkeleton />;
|
||||
|
||||
return (
|
||||
<div data-testid="llm-settings-screen" className="h-full">
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
<div className="p-9 flex flex-col gap-6">
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={view === "advanced"}
|
||||
onToggle={handleToggleAdvancedSettings}
|
||||
isToggled={view === "advanced"}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</SettingsSwitch>
|
||||
|
||||
{view === "basic" && (
|
||||
<div
|
||||
data-testid="llm-settings-form-basic"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{!isLoading && !isFetching && (
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={
|
||||
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
|
||||
}
|
||||
onChange={handleModelIsDirty}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
startContent={
|
||||
settings.LLM_API_KEY_SET && (
|
||||
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "advanced" && (
|
||||
<div
|
||||
data-testid="llm-settings-form-advanced"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<SettingsInput
|
||||
testId="llm-custom-model-input"
|
||||
name="llm-custom-model-input"
|
||||
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
|
||||
defaultValue={
|
||||
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
|
||||
}
|
||||
placeholder="anthropic/claude-3-5-sonnet-20241022"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
onChange={handleCustomModelIsDirty}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
name="base-url-input"
|
||||
label={t(I18nKey.SETTINGS$BASE_URL)}
|
||||
defaultValue={settings.LLM_BASE_URL}
|
||||
placeholder="https://api.openai.com"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
onChange={handleBaseUrlIsDirty}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
startContent={
|
||||
settings.LLM_API_KEY_SET && (
|
||||
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="agent-input"
|
||||
name="agent-input"
|
||||
label={t(I18nKey.SETTINGS$AGENT)}
|
||||
items={
|
||||
resources?.agents.map((agent) => ({
|
||||
key: agent,
|
||||
label: agent,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.AGENT}
|
||||
isClearable={false}
|
||||
onInputChange={handleAgentIsDirty}
|
||||
wrapperClassName="w-[680px]"
|
||||
/>
|
||||
|
||||
{config?.APP_MODE === "saas" && (
|
||||
<SettingsDropdownInput
|
||||
testId="runtime-settings-input"
|
||||
name="runtime-settings-input"
|
||||
label={
|
||||
<>
|
||||
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
|
||||
<a href="mailto:contact@all-hands.dev">
|
||||
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
}
|
||||
items={[]}
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-memory-condenser-switch"
|
||||
name="enable-memory-condenser-switch"
|
||||
defaultIsToggled={settings.ENABLE_DEFAULT_CONDENSER}
|
||||
onToggle={handleEnableDefaultCondenserIsDirty}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
|
||||
</SettingsSwitch>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-confirmation-mode-switch"
|
||||
name="enable-confirmation-mode-switch"
|
||||
onToggle={handleConfirmationModeIsDirty}
|
||||
defaultIsToggled={settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
|
||||
</SettingsSwitch>
|
||||
|
||||
{securityAnalyzerInputIsVisible && (
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security-analyzer-input"
|
||||
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
|
||||
items={
|
||||
resources?.securityAnalyzers.map((analyzer) => ({
|
||||
key: analyzer,
|
||||
label: analyzer,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.SECURITY_ANALYZER}
|
||||
isClearable
|
||||
showOptionalTag
|
||||
onInputChange={handleSecurityAnalyzerIsDirty}
|
||||
wrapperClassName="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!formIsDirty || isPending}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LlmSettingsScreen;
|
||||
@@ -21,6 +21,7 @@ import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -58,6 +59,7 @@ export function ErrorBoundary() {
|
||||
export default function MainApp() {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const tosPageStatus = useIsOnTosPage();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: settings } = useSettings();
|
||||
const { error, isFetching } = useBalance();
|
||||
@@ -71,49 +73,75 @@ export default function MainApp() {
|
||||
isError: authError,
|
||||
} = useIsAuthed();
|
||||
|
||||
// Always call the hook, but we'll only use the result when not on TOS page
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
appMode: config.data?.APP_MODE || null,
|
||||
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
|
||||
});
|
||||
|
||||
// When on TOS page, we don't use the GitHub auth URL
|
||||
const effectiveGitHubAuthUrl = tosPageStatus ? null : gitHubAuthUrl;
|
||||
|
||||
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings?.LANGUAGE) {
|
||||
// Don't change language when on TOS page
|
||||
if (!tosPageStatus && settings?.LANGUAGE) {
|
||||
i18n.changeLanguage(settings.LANGUAGE);
|
||||
}
|
||||
}, [settings?.LANGUAGE]);
|
||||
}, [settings?.LANGUAGE, tosPageStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const consentFormModalIsOpen =
|
||||
settings?.USER_CONSENTS_TO_ANALYTICS === null;
|
||||
// Don't show consent form when on TOS page
|
||||
if (!tosPageStatus) {
|
||||
const consentFormModalIsOpen =
|
||||
settings?.USER_CONSENTS_TO_ANALYTICS === null;
|
||||
|
||||
setConsentFormIsOpen(consentFormModalIsOpen);
|
||||
}, [settings]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Migrate user consent to the server if it was previously stored in localStorage
|
||||
migrateUserConsent({
|
||||
handleAnalyticsWasPresentInLocalStorage: () => {
|
||||
setConsentFormIsOpen(false);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Don't allow users to use the app if it 402s
|
||||
if (error?.status === 402 && pathname !== "/") {
|
||||
navigate("/");
|
||||
} else if (!isFetching && searchParams.get("free_credits") === "success") {
|
||||
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
|
||||
searchParams.delete("free_credits");
|
||||
navigate("/");
|
||||
setConsentFormIsOpen(consentFormModalIsOpen);
|
||||
}
|
||||
}, [error?.status, pathname, isFetching]);
|
||||
}, [settings, tosPageStatus]);
|
||||
|
||||
const userIsAuthed = !!isAuthed && !authError;
|
||||
React.useEffect(() => {
|
||||
// Don't migrate user consent when on TOS page
|
||||
if (!tosPageStatus) {
|
||||
// Migrate user consent to the server if it was previously stored in localStorage
|
||||
migrateUserConsent({
|
||||
handleAnalyticsWasPresentInLocalStorage: () => {
|
||||
setConsentFormIsOpen(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [tosPageStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Don't do any redirects when on TOS page
|
||||
if (!tosPageStatus) {
|
||||
// Don't allow users to use the app if it 402s
|
||||
if (error?.status === 402 && pathname !== "/") {
|
||||
navigate("/");
|
||||
} else if (
|
||||
!isFetching &&
|
||||
searchParams.get("free_credits") === "success"
|
||||
) {
|
||||
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
|
||||
searchParams.delete("free_credits");
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
}, [error?.status, pathname, isFetching, tosPageStatus]);
|
||||
|
||||
// When on TOS page, we don't make any API calls, so we need to handle this case
|
||||
const userIsAuthed = tosPageStatus ? false : !!isAuthed && !authError;
|
||||
|
||||
// Only show the auth modal if:
|
||||
// 1. User is not authenticated
|
||||
// 2. We're not currently on the TOS page
|
||||
// 3. We're in SaaS mode
|
||||
const renderAuthModal =
|
||||
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
|
||||
!isFetchingAuth &&
|
||||
!userIsAuthed &&
|
||||
!tosPageStatus &&
|
||||
config.data?.APP_MODE === "saas";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -131,7 +159,7 @@ export default function MainApp() {
|
||||
|
||||
{renderAuthModal && (
|
||||
<AuthModal
|
||||
githubAuthUrl={gitHubAuthUrl}
|
||||
githubAuthUrl={effectiveGitHubAuthUrl}
|
||||
appMode={config.data?.APP_MODE}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
@@ -7,9 +8,44 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
|
||||
const saasNavItems = [
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
|
||||
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
|
||||
];
|
||||
|
||||
const ossNavItems = [
|
||||
{ to: "/settings", text: t("SETTINGS$NAV_LLM") },
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSaas) {
|
||||
if (pathname === "/settings") {
|
||||
navigate("/settings/git");
|
||||
}
|
||||
} else {
|
||||
const noEnteringPaths = [
|
||||
"/settings/billing",
|
||||
"/settings/credits",
|
||||
"/settings/api-keys",
|
||||
];
|
||||
if (noEnteringPaths.includes(pathname)) {
|
||||
navigate("/settings");
|
||||
}
|
||||
}
|
||||
}, [isSaas, pathname]);
|
||||
|
||||
const navItems = isSaas ? saasNavItems : ossNavItems;
|
||||
|
||||
return (
|
||||
<main
|
||||
data-testid="settings-screen"
|
||||
@@ -20,32 +56,26 @@ function SettingsScreen() {
|
||||
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
|
||||
</header>
|
||||
|
||||
{isSaas && (
|
||||
<nav
|
||||
data-testid="settings-navbar"
|
||||
className="flex items-end gap-12 px-11 border-b border-tertiary"
|
||||
>
|
||||
{[
|
||||
{ to: "/settings", text: "Account" },
|
||||
{ to: "/settings/billing", text: "Credits" },
|
||||
{ to: "/settings/api-keys", text: "API Keys" },
|
||||
].map(({ to, text }) => (
|
||||
<NavLink
|
||||
end
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"border-b-2 border-transparent py-2.5",
|
||||
isActive && "border-primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<nav
|
||||
data-testid="settings-navbar"
|
||||
className="flex items-end gap-12 px-9 border-b border-tertiary"
|
||||
>
|
||||
{navItems.map(({ to, text }) => (
|
||||
<NavLink
|
||||
end
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"border-b-2 border-transparent py-2.5",
|
||||
isActive && "border-primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col grow overflow-auto">
|
||||
<Outlet />
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
@apply bg-tertiary border border-neutral-600 rounded;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply bg-gray-400 rounded-md animate-pulse;
|
||||
}
|
||||
|
||||
.skeleton-round {
|
||||
@apply bg-gray-400 rounded-full animate-pulse;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
export const hasAdvancedSettingsSet = (settings: Partial<Settings>): boolean =>
|
||||
!!settings.LLM_BASE_URL ||
|
||||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
|
||||
settings.REMOTE_RUNTIME_RESOURCE_FACTOR !==
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR ||
|
||||
settings.CONFIRMATION_MODE ||
|
||||
!!settings.SECURITY_ANALYZER;
|
||||
Object.keys(settings).length > 0 &&
|
||||
(!!settings.LLM_BASE_URL ||
|
||||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
|
||||
settings.CONFIRMATION_MODE ||
|
||||
!!settings.SECURITY_ANALYZER);
|
||||
|
||||
20
frontend/src/utils/is-on-tos-page.ts
Normal file
20
frontend/src/utils/is-on-tos-page.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Checks if the current page is the Terms of Service acceptance page.
|
||||
* This function works outside of React Router context by checking window.location directly.
|
||||
*
|
||||
* @param {string} [pathname] - Optional pathname from React Router's useLocation hook
|
||||
* @returns {boolean} True if the current page is the TOS acceptance page, false otherwise.
|
||||
*/
|
||||
export const isOnTosPage = (pathname?: string): boolean => {
|
||||
// If pathname is provided (from React Router), use it
|
||||
if (pathname !== undefined) {
|
||||
return pathname === "/accept-tos";
|
||||
}
|
||||
|
||||
// Otherwise check window.location (works outside React Router context)
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.pathname === "/accept-tos";
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -18,6 +18,10 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-on-tos-page", () => ({
|
||||
useIsOnTosPage: () => false,
|
||||
}));
|
||||
|
||||
// Mock requests during tests
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" }));
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: add_openhands_repo_instruction
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: REPO_FOLDER_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
required: false
|
||||
---
|
||||
|
||||
Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
|
||||
|
||||
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes (1) the purpose of this repository, (2) the general setup of this repo, and (3) a brief description of the structure of this repo.
|
||||
|
||||
Here's an example:
|
||||
```markdown
|
||||
---
|
||||
name: repo
|
||||
type: repo
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
|
||||
(in the `openhands` directory) and React frontend (in the `frontend` directory).
|
||||
|
||||
## General Setup:
|
||||
To set up the entire repo, including frontend and backend, run `make build`.
|
||||
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
|
||||
|
||||
Before pushing any changes, you should ensure that any lint errors or simple test errors have been fixed.
|
||||
|
||||
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
|
||||
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
|
||||
|
||||
If either command fails, it may have automatically fixed some issues. You should fix any issues that weren't automatically fixed,
|
||||
then re-run the command to ensure it passes.
|
||||
|
||||
## Repository Structure
|
||||
Backend:
|
||||
- Located in the `openhands` directory
|
||||
- Testing:
|
||||
- All tests are in `tests/unit/test_*.py`
|
||||
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
|
||||
- Write all tests with pytest
|
||||
|
||||
Frontend:
|
||||
- Located in the `frontend` directory
|
||||
- Prerequisites: A recent version of NodeJS / NPM
|
||||
- Setup: Run `npm install` in the frontend directory
|
||||
- Testing:
|
||||
- Run tests: `npm run test`
|
||||
- To run specific tests: `npm run test -- -t "TestName"`
|
||||
- Building:
|
||||
- Build for production: `npm run build`
|
||||
- Environment Variables:
|
||||
- Set in `frontend/.env` or as environment variables
|
||||
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
|
||||
- Internationalization:
|
||||
- Generate i18n declaration file: `npm run make-i18n`
|
||||
```
|
||||
|
||||
Now, please write a similar markdown for the current repository.
|
||||
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: address_pr_comments
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: PR_URL
|
||||
description: "URL of the pull request"
|
||||
required: true
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch name corresponds to the pull request"
|
||||
required: true
|
||||
---
|
||||
|
||||
First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose.
|
||||
|
||||
This branch corresponds to this PR {{ PR_URL }}
|
||||
|
||||
Next, you should use the GitHub API to read the reviews and comments on this PR and address them.
|
||||
32
microagents/agent_memory.md
Normal file
32
microagents/agent_memory.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: agent_memory
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- /remember
|
||||
---
|
||||
|
||||
* Repository memory: Use .openhands/microagents/repo.md under each repository root to store and access important information.
|
||||
- If this file exists, it will be added to your context automatically.
|
||||
- If missing, you should create it unless the user has explicitly asked you to not do so.
|
||||
|
||||
* Store and maintain **general knowledge** that will be helpful for most future tasks:
|
||||
1. Repository structure
|
||||
2. Common commands (build, lint, test, pre-commit, etc.)
|
||||
3. Code style preferences
|
||||
4. Workflows and best practices
|
||||
5. Any other repository-specific knowledge you learn
|
||||
|
||||
* IMPORTANT: ONLY LOG the information that would be helpful for different future tasks, for example, how to configure the settings, how to setup the repository. Do NOT add issue-specific information (e.g., what specific error you have ran into and how you fix it).
|
||||
|
||||
* When adding new information:
|
||||
- ALWAYS ask for user confirmation first by listing the exact items (numbered 1, 2, 3, etc.) you plan to save to repo.md
|
||||
- Only save the items the user approves (they may ask you to save a subset)
|
||||
- Ensure it integrates nicely with existing knowledge in repo.md
|
||||
- Reorganize the content if needed to maintain clarity and organization
|
||||
- Group related information together under appropriate sections or headings
|
||||
- If you've only explored a portion of the codebase, clearly note this limitation in the repository structure documentation
|
||||
- If you don't know the essential commands for working with the repository, such as lint or typecheck, ask the user and suggest adding them to repo.md for future reference (with permission)
|
||||
|
||||
When you receive this message, please review and summarize your recent actions and observations, then present a list of valuable information that should be saved in repo.md to the user.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: get_test_to_pass
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
required: true
|
||||
- name: TEST_COMMAND_TO_RUN
|
||||
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
|
||||
required: true
|
||||
- name: FUNCTION_TO_FIX
|
||||
description: "The name of function to fix"
|
||||
required: false
|
||||
- name: FILE_FOR_FUNCTION
|
||||
description: "The path of the file that contains the function"
|
||||
required: false
|
||||
---
|
||||
|
||||
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
|
||||
|
||||
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
|
||||
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
|
||||
{%- endif %}
|
||||
|
||||
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: update_pr_description
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: PR_URL
|
||||
description: "URL of the pull request"
|
||||
type: string
|
||||
required: true
|
||||
validation:
|
||||
pattern: "^https://github.com/.+/.+/pull/[0-9]+$"
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch name corresponds to the pull request"
|
||||
type: string
|
||||
required: true
|
||||
---
|
||||
|
||||
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
|
||||
|
||||
Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: update_test_for_new_implementation
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
required: true
|
||||
- name: TEST_COMMAND_TO_RUN
|
||||
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
|
||||
required: true
|
||||
---
|
||||
|
||||
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
|
||||
|
||||
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
|
||||
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
|
||||
{%- endif %}
|
||||
|
||||
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.
|
||||
@@ -20,10 +20,7 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.message import Message
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
AgentFinishAction,
|
||||
)
|
||||
from openhands.events.action import Action, AgentFinishAction, MessageAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.memory.condenser import Condenser
|
||||
@@ -173,7 +170,8 @@ class CodeActAgent(Agent):
|
||||
f'Processing {len(condensed_history)} events from a total of {len(state.history)} events'
|
||||
)
|
||||
|
||||
messages = self._get_messages(condensed_history)
|
||||
initial_user_message = self._get_initial_user_message(state.history)
|
||||
messages = self._get_messages(condensed_history, initial_user_message)
|
||||
params: dict = {
|
||||
'messages': self.llm.format_messages_for_llm(messages),
|
||||
}
|
||||
@@ -216,7 +214,29 @@ class CodeActAgent(Agent):
|
||||
self.pending_actions.append(action)
|
||||
return self.pending_actions.popleft()
|
||||
|
||||
def _get_messages(self, events: list[Event]) -> list[Message]:
|
||||
def _get_initial_user_message(self, history: list[Event]) -> MessageAction:
|
||||
"""Finds the initial user message action from the full history."""
|
||||
initial_user_message: MessageAction | None = None
|
||||
for event in history:
|
||||
if isinstance(event, MessageAction) and event.source == 'user':
|
||||
initial_user_message = event
|
||||
break
|
||||
|
||||
if initial_user_message is None:
|
||||
# This should not happen in a valid conversation
|
||||
logger.error(
|
||||
f'CRITICAL: Could not find the initial user MessageAction in the full {len(history)} events history.'
|
||||
)
|
||||
# Depending on desired robustness, could raise error or create a dummy action
|
||||
# and log the error
|
||||
raise ValueError(
|
||||
'Initial user message not found in history. Please report this issue.'
|
||||
)
|
||||
return initial_user_message
|
||||
|
||||
def _get_messages(
|
||||
self, events: list[Event], initial_user_message: MessageAction
|
||||
) -> list[Message]:
|
||||
"""Constructs the message history for the LLM conversation.
|
||||
|
||||
This method builds a structured conversation history by processing events from the state
|
||||
@@ -253,6 +273,7 @@ class CodeActAgent(Agent):
|
||||
# Use ConversationMemory to process events (including SystemMessageAction)
|
||||
messages = self.conversation_memory.process_events(
|
||||
condensed_history=events,
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=self.llm.config.max_message_chars,
|
||||
vision_is_active=self.llm.vision_is_active(),
|
||||
)
|
||||
|
||||
@@ -44,8 +44,9 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
* For bug fixes: Create tests to verify issues before implementing fixes
|
||||
* For new features: Consider test-driven development when appropriate
|
||||
* If the repository lacks testing infrastructure and implementing tests would require extensive setup, consult with the user before investing time in building testing infrastructure
|
||||
* If the environment is not set up to run tests, consult with the user first before investing time to install all dependencies
|
||||
4. IMPLEMENTATION: Make focused, minimal changes to address the problem
|
||||
5. VERIFICATION: Test your implementation thoroughly, including edge cases
|
||||
5. VERIFICATION: If the environment is set up to run tests, test your implementation thoroughly, including edge cases. If the environment is not set up to run tests, consult with the user first before investing time to run tests.
|
||||
</PROBLEM_SOLVING_WORKFLOW>
|
||||
|
||||
<SECURITY>
|
||||
|
||||
@@ -76,6 +76,8 @@ from openhands.llm.metrics import Metrics, TokenUsage
|
||||
TRAFFIC_CONTROL_REMINDER = (
|
||||
"Please click on resume button if you'd like to continue, or start a new task."
|
||||
)
|
||||
ERROR_ACTION_NOT_EXECUTED_ID = 'AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED'
|
||||
ERROR_ACTION_NOT_EXECUTED = 'The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
|
||||
|
||||
|
||||
class AgentController:
|
||||
@@ -190,7 +192,7 @@ class AgentController:
|
||||
logger.debug(f'System message got from agent: {system_message}')
|
||||
if system_message:
|
||||
self.event_stream.add_event(system_message, EventSource.AGENT)
|
||||
logger.debug(f'System message added to event stream: {system_message}')
|
||||
logger.info(f'System message added to event stream: {system_message}')
|
||||
|
||||
async def close(self, set_stop_state: bool = True) -> None:
|
||||
"""Closes the agent controller, canceling any ongoing tasks and unsubscribing from the event stream.
|
||||
@@ -566,7 +568,10 @@ class AgentController:
|
||||
|
||||
# make a new ErrorObservation with the tool call metadata
|
||||
if not found_observation:
|
||||
obs = ErrorObservation(content='The action has not been executed.')
|
||||
obs = ErrorObservation(
|
||||
content=ERROR_ACTION_NOT_EXECUTED,
|
||||
error_id=ERROR_ACTION_NOT_EXECUTED_ID,
|
||||
)
|
||||
obs.tool_call_metadata = self._pending_action.tool_call_metadata
|
||||
obs._cause = self._pending_action.id # type: ignore[attr-defined]
|
||||
self.event_stream.add_event(obs, EventSource.AGENT)
|
||||
@@ -842,6 +847,8 @@ class AgentController:
|
||||
'contextwindowexceedederror' in error_str
|
||||
or 'prompt is too long' in error_str
|
||||
or 'input length and `max_tokens` exceed context limit' in error_str
|
||||
or 'please reduce the length of either one'
|
||||
in error_str # For OpenRouter context window errors
|
||||
or isinstance(e, ContextWindowExceededError)
|
||||
):
|
||||
if self.agent.config.enable_history_truncation:
|
||||
@@ -1020,7 +1027,7 @@ class AgentController:
|
||||
self.state.start_id = 0
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
'info',
|
||||
f'AgentController {self.id} - created new state. start_id: {self.state.start_id}',
|
||||
)
|
||||
else:
|
||||
@@ -1030,7 +1037,7 @@ class AgentController:
|
||||
self.state.start_id = 0
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
'info',
|
||||
f'AgentController {self.id} initializing history from event {self.state.start_id}',
|
||||
)
|
||||
|
||||
@@ -1143,70 +1150,169 @@ class AgentController:
|
||||
|
||||
def _handle_long_context_error(self) -> None:
|
||||
# When context window is exceeded, keep roughly half of agent interactions
|
||||
kept_event_ids = {
|
||||
e.id for e in self._apply_conversation_window(self.state.history)
|
||||
}
|
||||
kept_events = self._apply_conversation_window()
|
||||
kept_event_ids = {e.id for e in kept_events}
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
f'Context window exceeded. Keeping events with IDs: {kept_event_ids}',
|
||||
)
|
||||
|
||||
# The events to forget are those that are not in the kept set
|
||||
forgotten_event_ids = {e.id for e in self.state.history} - kept_event_ids
|
||||
|
||||
# Save the ID of the first event in our truncated history for future reloading
|
||||
if self.state.history:
|
||||
self.state.start_id = self.state.history[0].id
|
||||
if len(kept_event_ids) == 0:
|
||||
self.log(
|
||||
'warning',
|
||||
'No events kept after applying conversation window. This should not happen.',
|
||||
)
|
||||
|
||||
# verify that the first event id in kept_event_ids is the same as the start_id
|
||||
if len(kept_event_ids) > 0 and self.state.history[0].id not in kept_event_ids:
|
||||
self.log(
|
||||
'warning',
|
||||
f'First event after applying conversation window was not kept: {self.state.history[0].id} not in {kept_event_ids}',
|
||||
)
|
||||
|
||||
# Add an error event to trigger another step by the agent
|
||||
self.event_stream.add_event(
|
||||
CondensationAction(
|
||||
forgotten_events_start_id=min(forgotten_event_ids),
|
||||
forgotten_events_end_id=max(forgotten_event_ids),
|
||||
forgotten_events_start_id=min(forgotten_event_ids)
|
||||
if forgotten_event_ids
|
||||
else 0,
|
||||
forgotten_events_end_id=max(forgotten_event_ids)
|
||||
if forgotten_event_ids
|
||||
else 0,
|
||||
),
|
||||
EventSource.AGENT,
|
||||
)
|
||||
|
||||
def _apply_conversation_window(self, events: list[Event]) -> list[Event]:
|
||||
def _apply_conversation_window(self) -> list[Event]:
|
||||
"""Cuts history roughly in half when context window is exceeded.
|
||||
|
||||
It preserves action-observation pairs and ensures that the first user message is always included.
|
||||
It preserves action-observation pairs and ensures that the system message,
|
||||
the first user message, and its associated recall observation are always included
|
||||
at the beginning of the context window.
|
||||
|
||||
The algorithm:
|
||||
1. Cut history in half
|
||||
2. Check first event in new history:
|
||||
- If Observation: find and include its Action
|
||||
- If MessageAction: ensure its related Action-Observation pair isn't split
|
||||
3. Always include the first user message
|
||||
1. Identify essential initial events: System Message, First User Message, Recall Observation.
|
||||
2. Determine the slice of recent events to potentially keep.
|
||||
3. Validate the start of the recent slice for dangling observations.
|
||||
4. Combine essential events and validated recent events, ensuring essentials come first.
|
||||
|
||||
Args:
|
||||
events: List of events to filter
|
||||
|
||||
Returns:
|
||||
Filtered list of events keeping newest half while preserving pairs
|
||||
Filtered list of events keeping newest half while preserving pairs and essential initial events.
|
||||
"""
|
||||
if not events:
|
||||
return events
|
||||
if not self.state.history:
|
||||
return []
|
||||
|
||||
# Find first user message - we'll need to ensure it's included
|
||||
first_user_msg = next(
|
||||
(
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, MessageAction) and e.source == EventSource.USER
|
||||
),
|
||||
None,
|
||||
history = self.state.history
|
||||
|
||||
# 1. Identify essential initial events
|
||||
system_message: SystemMessageAction | None = None
|
||||
first_user_msg: MessageAction | None = None
|
||||
recall_action: RecallAction | None = None
|
||||
recall_observation: Observation | None = None
|
||||
|
||||
# Find System Message (should be the first event, if it exists)
|
||||
system_message = next(
|
||||
(e for e in history if isinstance(e, SystemMessageAction)), None
|
||||
)
|
||||
assert (
|
||||
system_message is None
|
||||
or isinstance(system_message, SystemMessageAction)
|
||||
and system_message.id == history[0].id
|
||||
)
|
||||
|
||||
# cut in half
|
||||
mid_point = max(1, len(events) // 2)
|
||||
kept_events = events[mid_point:]
|
||||
if len(kept_events) > 0 and isinstance(kept_events[0], Observation):
|
||||
kept_events = kept_events[1:]
|
||||
# Find First User Message, which MUST exist
|
||||
first_user_msg = self._first_user_message()
|
||||
if first_user_msg is None:
|
||||
raise RuntimeError('No first user message found in the event stream.')
|
||||
|
||||
# Ensure first user message is included
|
||||
if first_user_msg and first_user_msg not in kept_events:
|
||||
kept_events = [first_user_msg] + kept_events
|
||||
first_user_msg_index = -1
|
||||
for i, event in enumerate(history):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
first_user_msg = event
|
||||
first_user_msg_index = i
|
||||
break
|
||||
|
||||
# start_id points to first user message
|
||||
# Find Recall Action and Observation related to the First User Message
|
||||
if first_user_msg is not None and first_user_msg_index != -1:
|
||||
# Look for RecallAction after the first user message
|
||||
for i in range(first_user_msg_index + 1, len(history)):
|
||||
event = history[i]
|
||||
if (
|
||||
isinstance(event, RecallAction)
|
||||
and event.query == first_user_msg.content
|
||||
):
|
||||
# Found RecallAction, now look for its Observation
|
||||
recall_action = event
|
||||
for j in range(i + 1, len(history)):
|
||||
obs_event = history[j]
|
||||
# Check for Observation caused by this RecallAction
|
||||
if (
|
||||
isinstance(obs_event, Observation)
|
||||
and obs_event.cause == recall_action.id
|
||||
):
|
||||
recall_observation = obs_event
|
||||
break # Found the observation, stop inner loop
|
||||
break # Found the recall action (and maybe obs), stop outer loop
|
||||
|
||||
essential_events: list[Event] = []
|
||||
if system_message:
|
||||
essential_events.append(system_message)
|
||||
if first_user_msg:
|
||||
self.state.start_id = first_user_msg.id
|
||||
essential_events.append(first_user_msg)
|
||||
# Also keep the RecallAction that triggered the essential RecallObservation
|
||||
if recall_action:
|
||||
essential_events.append(recall_action)
|
||||
if recall_observation:
|
||||
essential_events.append(recall_observation)
|
||||
|
||||
return kept_events
|
||||
# 2. Determine the slice of recent events to potentially keep
|
||||
num_non_essential_events = len(history) - len(essential_events)
|
||||
# Keep roughly half of the non-essential events, minimum 1
|
||||
num_recent_to_keep = max(1, num_non_essential_events // 2)
|
||||
|
||||
# Calculate the starting index for the recent slice
|
||||
slice_start_index = len(history) - num_recent_to_keep
|
||||
slice_start_index = max(0, slice_start_index) # Ensure index is not negative
|
||||
recent_events_slice = history[slice_start_index:]
|
||||
|
||||
# 3. Validate the start of the recent slice for dangling observations
|
||||
# IMPORTANT: Most observations in history are tool call results, which cannot be without their action, or we get an LLM API error
|
||||
first_valid_event_index = 0
|
||||
for i, event in enumerate(recent_events_slice):
|
||||
if isinstance(event, Observation):
|
||||
first_valid_event_index += 1
|
||||
else:
|
||||
break
|
||||
# If all events in the slice are dangling observations, we need to keep at least one
|
||||
if first_valid_event_index == len(recent_events_slice):
|
||||
self.log(
|
||||
'warning',
|
||||
'All recent events are dangling observations, which we truncate. This means the agent has only the essential first events. This should not happen.',
|
||||
)
|
||||
|
||||
# Adjust the recent_events_slice if dangling observations were found at the start
|
||||
if first_valid_event_index < len(recent_events_slice):
|
||||
validated_recent_events = recent_events_slice[first_valid_event_index:]
|
||||
if first_valid_event_index > 0:
|
||||
self.log(
|
||||
'debug',
|
||||
f'Removed {first_valid_event_index} dangling observation(s) from the start of recent event slice.',
|
||||
)
|
||||
else:
|
||||
validated_recent_events = []
|
||||
|
||||
# 4. Combine essential events and validated recent events
|
||||
events_to_keep: list[Event] = essential_events + validated_recent_events
|
||||
self.log('debug', f'History truncated. Kept {len(events_to_keep)} events.')
|
||||
|
||||
return events_to_keep
|
||||
|
||||
def _is_stuck(self) -> bool:
|
||||
"""Checks if the agent or its delegate is stuck in a loop.
|
||||
|
||||
@@ -14,12 +14,14 @@ from openhands.core.cli_commands import (
|
||||
)
|
||||
from openhands.core.cli_tui import (
|
||||
UsageMetrics,
|
||||
display_agent_running_message,
|
||||
display_banner,
|
||||
display_event,
|
||||
display_initial_user_prompt,
|
||||
display_initialization_animation,
|
||||
display_runtime_initialization_message,
|
||||
display_welcome_message,
|
||||
process_agent_pause,
|
||||
read_confirmation_input,
|
||||
read_prompt_input,
|
||||
)
|
||||
@@ -99,6 +101,7 @@ async def run_session(
|
||||
|
||||
sid = str(uuid4())
|
||||
is_loaded = asyncio.Event()
|
||||
is_paused = asyncio.Event()
|
||||
|
||||
# Show runtime initialization message
|
||||
display_runtime_initialization_message(config.runtime)
|
||||
@@ -124,10 +127,12 @@ async def run_session(
|
||||
|
||||
usage_metrics = UsageMetrics()
|
||||
|
||||
async def prompt_for_next_task():
|
||||
async def prompt_for_next_task(agent_state: str):
|
||||
nonlocal reload_microagents, new_session_requested
|
||||
while True:
|
||||
next_message = await read_prompt_input(config.cli_multiline_input)
|
||||
next_message = await read_prompt_input(
|
||||
agent_state, multiline=config.cli_multiline_input
|
||||
)
|
||||
|
||||
if not next_message.strip():
|
||||
continue
|
||||
@@ -150,14 +155,23 @@ async def run_session(
|
||||
return
|
||||
|
||||
async def on_event_async(event: Event) -> None:
|
||||
nonlocal reload_microagents
|
||||
nonlocal reload_microagents, is_paused
|
||||
display_event(event, config)
|
||||
update_usage_metrics(event, usage_metrics)
|
||||
|
||||
# Pause the agent if the pause event is set (if Ctrl-P is pressed)
|
||||
if is_paused.is_set():
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.PAUSED),
|
||||
EventSource.USER,
|
||||
)
|
||||
is_paused.clear()
|
||||
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
if event.agent_state in [
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
AgentState.PAUSED,
|
||||
]:
|
||||
# Reload microagents after initialization of repo.md
|
||||
if reload_microagents:
|
||||
@@ -166,20 +180,28 @@ async def run_session(
|
||||
)
|
||||
memory.load_user_workspace_microagents(microagents)
|
||||
reload_microagents = False
|
||||
await prompt_for_next_task()
|
||||
await prompt_for_next_task(event.agent_state)
|
||||
|
||||
if event.agent_state == AgentState.AWAITING_USER_CONFIRMATION:
|
||||
user_confirmed = await read_confirmation_input()
|
||||
if user_confirmed:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
|
||||
EventSource.USER,
|
||||
)
|
||||
else:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_REJECTED),
|
||||
EventSource.USER,
|
||||
)
|
||||
# Only display the confirmation prompt if the agent is not paused
|
||||
if not is_paused.is_set():
|
||||
user_confirmed = await read_confirmation_input()
|
||||
if user_confirmed:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
|
||||
EventSource.USER,
|
||||
)
|
||||
else:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_REJECTED),
|
||||
EventSource.USER,
|
||||
)
|
||||
|
||||
if event.agent_state == AgentState.RUNNING:
|
||||
# Enable pause/resume functionality only if the confirmation mode is disabled
|
||||
if not config.security.confirmation_mode:
|
||||
display_agent_running_message()
|
||||
loop.create_task(process_agent_pause(is_paused))
|
||||
|
||||
def on_event(event: Event) -> None:
|
||||
loop.create_task(on_event_async(event))
|
||||
@@ -212,7 +234,7 @@ async def run_session(
|
||||
clear()
|
||||
|
||||
# Show OpenHands banner and session ID
|
||||
display_banner(session_id=sid, is_loaded=is_loaded)
|
||||
display_banner(session_id=sid)
|
||||
|
||||
# Show OpenHands welcome
|
||||
display_welcome_message()
|
||||
@@ -225,7 +247,7 @@ async def run_session(
|
||||
)
|
||||
else:
|
||||
# Otherwise prompt for the user's first message right away
|
||||
asyncio.create_task(prompt_for_next_task())
|
||||
asyncio.create_task(prompt_for_next_task(''))
|
||||
|
||||
await run_agent_until_done(
|
||||
controller, runtime, memory, [AgentState.STOPPED, AgentState.ERROR]
|
||||
|
||||
@@ -70,6 +70,8 @@ async def handle_commands(
|
||||
)
|
||||
elif command == '/settings':
|
||||
await handle_settings_command(config, settings_store)
|
||||
elif command == '/resume':
|
||||
close_repl, new_session_requested = await handle_resume_command(event_stream)
|
||||
else:
|
||||
close_repl = True
|
||||
action = MessageAction(content=command)
|
||||
@@ -183,6 +185,28 @@ async def handle_settings_command(
|
||||
await modify_llm_settings_advanced(config, settings_store)
|
||||
|
||||
|
||||
# FIXME: Currently there's an issue with the actual 'resume' behavior.
|
||||
# Setting the agent state to RUNNING will currently freeze the agent without continuing with the rest of the task.
|
||||
# This is a workaround to handle the resume command for the time being. Replace user message with the state change event once the issue is fixed.
|
||||
async def handle_resume_command(
|
||||
event_stream: EventStream,
|
||||
) -> tuple[bool, bool]:
|
||||
close_repl = True
|
||||
new_session_requested = False
|
||||
|
||||
event_stream.add_event(
|
||||
MessageAction(content='continue'),
|
||||
EventSource.USER,
|
||||
)
|
||||
|
||||
# event_stream.add_event(
|
||||
# ChangeAgentStateAction(AgentState.RUNNING),
|
||||
# EventSource.ENVIRONMENT,
|
||||
# )
|
||||
|
||||
return close_repl, new_session_requested
|
||||
|
||||
|
||||
async def init_repository(current_dir: str) -> bool:
|
||||
repo_file_path = Path(current_dir) / '.openhands' / 'microagents' / 'repo.md'
|
||||
init_repo = False
|
||||
|
||||
@@ -10,7 +10,9 @@ from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
from prompt_toolkit.formatted_text import HTML, FormattedText, StyleAndTextTuples
|
||||
from prompt_toolkit.input import create_input
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.keys import Keys
|
||||
from prompt_toolkit.layout.containers import HSplit, Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.layout.layout import Layout
|
||||
@@ -22,6 +24,7 @@ from prompt_toolkit.widgets import Frame, TextArea
|
||||
|
||||
from openhands import __version__
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events import EventSource
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
@@ -32,6 +35,7 @@ from openhands.events.action import (
|
||||
)
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
CmdOutputObservation,
|
||||
FileEditObservation,
|
||||
FileReadObservation,
|
||||
@@ -56,6 +60,7 @@ COMMANDS = {
|
||||
'/status': 'Display session details and usage metrics',
|
||||
'/new': 'Create a new session',
|
||||
'/settings': 'Display and modify current settings',
|
||||
'/resume': 'Resume the agent',
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +119,7 @@ def display_initialization_animation(text, is_loaded: asyncio.Event):
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def display_banner(session_id: str, is_loaded: asyncio.Event):
|
||||
def display_banner(session_id: str):
|
||||
print_formatted_text(
|
||||
HTML(r"""<gold>
|
||||
___ _ _ _
|
||||
@@ -129,11 +134,8 @@ def display_banner(session_id: str, is_loaded: asyncio.Event):
|
||||
|
||||
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
|
||||
|
||||
banner_text = (
|
||||
'Initialized session' if is_loaded.is_set() else 'Initializing session'
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML(f'<grey>{banner_text} {session_id}</grey>'))
|
||||
print_formatted_text(HTML(f'<grey>Initialized session {session_id}</grey>'))
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
@@ -177,6 +179,8 @@ def display_event(event: Event, config: AppConfig) -> None:
|
||||
display_file_edit(event)
|
||||
if isinstance(event, FileReadObservation):
|
||||
display_file_read(event)
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
display_agent_paused_message(event.agent_state)
|
||||
|
||||
|
||||
def display_message(message: str):
|
||||
@@ -389,77 +393,58 @@ def display_status(usage_metrics: UsageMetrics, session_id: str):
|
||||
display_usage_metrics(usage_metrics)
|
||||
|
||||
|
||||
def display_agent_running_message():
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<gold>Agent running...</gold> <grey>(Ctrl-P to pause)</grey>')
|
||||
)
|
||||
|
||||
|
||||
def display_agent_paused_message(agent_state: str):
|
||||
if agent_state != AgentState.PAUSED:
|
||||
return
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<gold>Agent paused</gold> <grey>(type /resume to resume)</grey>')
|
||||
)
|
||||
|
||||
|
||||
# Common input functions
|
||||
class CommandCompleter(Completer):
|
||||
"""Custom completer for commands."""
|
||||
|
||||
def __init__(self, agent_state: str):
|
||||
super().__init__()
|
||||
self.agent_state = agent_state
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text
|
||||
|
||||
# Only show completions if the user has typed '/'
|
||||
text = document.text_before_cursor.lstrip()
|
||||
if text.startswith('/'):
|
||||
# If just '/' is typed, show all commands
|
||||
if text == '/':
|
||||
for command, description in COMMANDS.items():
|
||||
available_commands = dict(COMMANDS)
|
||||
if self.agent_state != AgentState.PAUSED:
|
||||
available_commands.pop('/resume', None)
|
||||
|
||||
for command, description in available_commands.items():
|
||||
if command.startswith(text):
|
||||
yield Completion(
|
||||
command[1:], # Remove the leading '/' as it's already typed
|
||||
start_position=0,
|
||||
display=f'{command} - {description}',
|
||||
command,
|
||||
start_position=-len(text),
|
||||
display_meta=description,
|
||||
style='bg:ansidarkgray fg:ansiwhite',
|
||||
)
|
||||
# Otherwise show matching commands
|
||||
else:
|
||||
for command, description in COMMANDS.items():
|
||||
if command.startswith(text):
|
||||
yield Completion(
|
||||
command[len(text) :], # Complete the remaining part
|
||||
start_position=0,
|
||||
display=f'{command} - {description}',
|
||||
)
|
||||
|
||||
|
||||
prompt_session = PromptSession(style=DEFAULT_STYLE)
|
||||
|
||||
# RPrompt animation related variables
|
||||
SPINNER_FRAMES = [
|
||||
'[ ■□□□ ]',
|
||||
'[ □■□□ ]',
|
||||
'[ □□■□ ]',
|
||||
'[ □□□■ ]',
|
||||
'[ □□■□ ]',
|
||||
'[ □■□□ ]',
|
||||
]
|
||||
ANIMATION_INTERVAL = 0.2 # seconds
|
||||
|
||||
current_frame_index = 0
|
||||
last_update_time = time.monotonic()
|
||||
def create_prompt_session():
|
||||
return PromptSession(style=DEFAULT_STYLE)
|
||||
|
||||
|
||||
# RPrompt function for the user confirmation
|
||||
def get_rprompt() -> FormattedText:
|
||||
"""
|
||||
Returns the current animation frame for the rprompt.
|
||||
This function is called by prompt_toolkit during rendering.
|
||||
"""
|
||||
global current_frame_index, last_update_time
|
||||
|
||||
# Only update the frame if enough time has passed
|
||||
# This prevents excessive recalculation during rendering
|
||||
now = time.monotonic()
|
||||
if now - last_update_time > ANIMATION_INTERVAL:
|
||||
current_frame_index = (current_frame_index + 1) % len(SPINNER_FRAMES)
|
||||
last_update_time = now
|
||||
|
||||
# Return the frame wrapped in FormattedText
|
||||
return FormattedText(
|
||||
[
|
||||
('', ' '), # Add a space before the spinner
|
||||
(COLOR_GOLD, SPINNER_FRAMES[current_frame_index]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def read_prompt_input(multiline=False):
|
||||
async def read_prompt_input(agent_state: str, multiline=False):
|
||||
try:
|
||||
prompt_session = create_prompt_session()
|
||||
prompt_session.completer = (
|
||||
CommandCompleter(agent_state) if not multiline else None
|
||||
)
|
||||
|
||||
if multiline:
|
||||
kb = KeyBindings()
|
||||
|
||||
@@ -470,38 +455,54 @@ async def read_prompt_input(multiline=False):
|
||||
with patch_stdout():
|
||||
print_formatted_text('')
|
||||
message = await prompt_session.prompt_async(
|
||||
'Enter your message and press Ctrl+D to finish:\n',
|
||||
HTML(
|
||||
'<gold>Enter your message and press Ctrl-D to finish:</gold>\n'
|
||||
),
|
||||
multiline=True,
|
||||
key_bindings=kb,
|
||||
)
|
||||
else:
|
||||
with patch_stdout():
|
||||
print_formatted_text('')
|
||||
prompt_session.completer = CommandCompleter()
|
||||
message = await prompt_session.prompt_async(
|
||||
'> ',
|
||||
HTML('<gold>> </gold>'),
|
||||
)
|
||||
return message
|
||||
return message if message is not None else ''
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return '/exit'
|
||||
|
||||
|
||||
async def read_confirmation_input():
|
||||
async def read_confirmation_input() -> bool:
|
||||
try:
|
||||
prompt_session = create_prompt_session()
|
||||
|
||||
with patch_stdout():
|
||||
prompt_session.completer = None
|
||||
confirmation = await prompt_session.prompt_async(
|
||||
'Proceed with action? (y)es/(n)o > ',
|
||||
rprompt=get_rprompt,
|
||||
refresh_interval=ANIMATION_INTERVAL / 2,
|
||||
print_formatted_text('')
|
||||
confirmation: str = await prompt_session.prompt_async(
|
||||
HTML('<gold>Proceed with action? (y)es/(n)o > </gold>'),
|
||||
)
|
||||
prompt_session.rprompt = None
|
||||
confirmation = confirmation.strip().lower()
|
||||
|
||||
confirmation = '' if confirmation is None else confirmation.strip().lower()
|
||||
return confirmation in ['y', 'yes']
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return False
|
||||
|
||||
|
||||
async def process_agent_pause(done: asyncio.Event) -> None:
|
||||
input = create_input()
|
||||
|
||||
def keys_ready():
|
||||
for key_press in input.read_keys():
|
||||
if key_press.key == Keys.ControlP:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
|
||||
done.set()
|
||||
|
||||
with input.raw_mode():
|
||||
with input.attach(keys_ready):
|
||||
await done.wait()
|
||||
|
||||
|
||||
def cli_confirm(
|
||||
question: str = 'Are you sure?', choices: list[str] | None = None
|
||||
) -> int:
|
||||
|
||||
@@ -60,6 +60,10 @@ PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Annotated[
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
WithJsonSchema({'type': 'object', 'additionalProperties': {'type': 'string'}}),
|
||||
]
|
||||
CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Annotated[
|
||||
CUSTOM_SECRETS_TYPE,
|
||||
WithJsonSchema({'type': 'object', 'additionalProperties': {'type': 'string'}}),
|
||||
]
|
||||
|
||||
|
||||
class SecretStore(BaseModel):
|
||||
@@ -67,8 +71,8 @@ class SecretStore(BaseModel):
|
||||
default_factory=lambda: MappingProxyType({})
|
||||
)
|
||||
|
||||
custom_secrets: CUSTOM_SECRETS_TYPE = Field(
|
||||
default_factory=lambda: MappingProxyType({})
|
||||
custom_secrets: CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Field(
|
||||
default_factory=lambda: MappingProxyType({}),
|
||||
)
|
||||
|
||||
model_config = {
|
||||
|
||||
@@ -54,6 +54,7 @@ class ConversationMemory:
|
||||
def process_events(
|
||||
self,
|
||||
condensed_history: list[Event],
|
||||
initial_user_action: MessageAction,
|
||||
max_message_chars: int | None = None,
|
||||
vision_is_active: bool = False,
|
||||
) -> list[Message]:
|
||||
@@ -66,12 +67,14 @@ class ConversationMemory:
|
||||
max_message_chars: The maximum number of characters in the content of an event included
|
||||
in the prompt to the LLM. Larger observations are truncated.
|
||||
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included.
|
||||
initial_user_action: The initial user message action, if available. Used to ensure the conversation starts correctly.
|
||||
"""
|
||||
|
||||
events = condensed_history
|
||||
|
||||
# Ensure the system message exists (handles legacy cases)
|
||||
# Ensure the event list starts with SystemMessageAction, then MessageAction(source='user')
|
||||
self._ensure_system_message(events)
|
||||
self._ensure_initial_user_message(events, initial_user_action)
|
||||
|
||||
# log visual browsing status
|
||||
logger.debug(f'Visual browsing: {self.agent_config.enable_som_visual_browsing}')
|
||||
@@ -227,8 +230,8 @@ class ConversationMemory:
|
||||
pending_tool_call_action_messages[llm_response.id] = Message(
|
||||
role=getattr(assistant_msg, 'role', 'assistant'),
|
||||
# tool call content SHOULD BE a string
|
||||
content=[TextContent(text=assistant_msg.content or '')]
|
||||
if assistant_msg.content is not None
|
||||
content=[TextContent(text=assistant_msg.content)]
|
||||
if assistant_msg.content and assistant_msg.content.strip()
|
||||
else [],
|
||||
tool_calls=assistant_msg.tool_calls,
|
||||
)
|
||||
@@ -699,6 +702,43 @@ class ConversationMemory:
|
||||
system_message = SystemMessageAction(content=system_prompt)
|
||||
# Insert the system message directly at the beginning of the events list
|
||||
events.insert(0, system_message)
|
||||
logger.debug(
|
||||
logger.info(
|
||||
'[ConversationMemory] Added SystemMessageAction for backward compatibility'
|
||||
)
|
||||
|
||||
def _ensure_initial_user_message(
|
||||
self, events: list[Event], initial_user_action: MessageAction
|
||||
) -> None:
|
||||
"""Checks if the second event is a user MessageAction and inserts the provided one if needed."""
|
||||
if (
|
||||
not events
|
||||
): # Should have system message from previous step, but safety check
|
||||
logger.error('Cannot ensure initial user message: event list is empty.')
|
||||
# Or raise? Let's log for now, _ensure_system_message should handle this.
|
||||
return
|
||||
|
||||
# We expect events[0] to be SystemMessageAction after _ensure_system_message
|
||||
if len(events) == 1:
|
||||
# Only system message exists
|
||||
logger.info(
|
||||
'Initial user message action was missing. Inserting the initial user message.'
|
||||
)
|
||||
events.insert(1, initial_user_action)
|
||||
elif not isinstance(events[1], MessageAction) or events[1].source != 'user':
|
||||
# The second event exists but is not the correct initial user message action.
|
||||
# We will insert the correct one provided.
|
||||
logger.info(
|
||||
'Second event was not the initial user message action. Inserting correct one at index 1.'
|
||||
)
|
||||
|
||||
# Insert the user message event at index 1. This will be the second message as LLM APIs expect
|
||||
# but something was wrong with the history, so log all we can.
|
||||
events.insert(1, initial_user_action)
|
||||
|
||||
# Else: events[1] is already a user MessageAction.
|
||||
# Check if it matches the one provided (if any discrepancy, log warning but proceed).
|
||||
elif events[1] != initial_user_action:
|
||||
logger.debug(
|
||||
'The user MessageAction at index 1 does not match the provided initial_user_action. '
|
||||
'Proceeding with the one found in condensed history.'
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import overload
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
@@ -65,6 +66,8 @@ class View(BaseModel):
|
||||
break
|
||||
|
||||
if summary is not None and summary_offset is not None:
|
||||
logger.info(f'Inserting summary at offset {summary_offset}')
|
||||
|
||||
kept_events.insert(
|
||||
summary_offset, AgentCondensationObservation(content=summary)
|
||||
)
|
||||
|
||||
@@ -98,14 +98,21 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
return self.runtime_url
|
||||
|
||||
async def connect(self):
|
||||
self.log('debug', f'Connecting to remote runtime with session ID: {self.sid}')
|
||||
try:
|
||||
self.log('debug', 'Starting or attaching to runtime...')
|
||||
await call_sync_from_async(self._start_or_attach_to_runtime)
|
||||
except Exception:
|
||||
self.log('debug', 'Runtime started/attached successfully')
|
||||
except Exception as e:
|
||||
self.log('error', f'Runtime failed to start: {str(e)}')
|
||||
self.close()
|
||||
self.log('error', 'Runtime failed to start')
|
||||
raise
|
||||
|
||||
self.log('debug', 'Setting up initial environment...')
|
||||
await call_sync_from_async(self.setup_initial_env)
|
||||
self.log('debug', 'Initial environment setup complete')
|
||||
self._runtime_initialized = True
|
||||
self.log('info', f'Remote runtime connection established. Runtime ID: {self.runtime_id}, URL: {self.runtime_url}')
|
||||
|
||||
def _start_or_attach_to_runtime(self):
|
||||
existing_runtime = self._check_existing_runtime()
|
||||
@@ -145,17 +152,21 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
self.send_status_message(' ')
|
||||
|
||||
def _check_existing_runtime(self) -> bool:
|
||||
self.log('debug', f'Checking for existing runtime with session ID: {self.sid}')
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
|
||||
)
|
||||
data = response.json()
|
||||
self.log('debug', f'Existing runtime check response: {data}')
|
||||
status = data.get('status')
|
||||
if status == 'running' or status == 'paused':
|
||||
self.log('debug', f'Found existing runtime with status: {status}')
|
||||
self._parse_runtime_response(response)
|
||||
except httpx.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
self.log('debug', f'No existing runtime found for session ID: {self.sid}')
|
||||
return False
|
||||
self.log('debug', f'Error while looking for remote runtime: {e}')
|
||||
raise
|
||||
@@ -167,6 +178,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
raise
|
||||
|
||||
if status == 'running':
|
||||
self.log('info', f'Using existing running runtime for session ID: {self.sid}')
|
||||
return True
|
||||
elif status == 'stopped':
|
||||
self.log('debug', 'Found existing remote runtime, but it is stopped')
|
||||
@@ -224,16 +236,20 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
)
|
||||
|
||||
def _start_runtime(self):
|
||||
self.log('debug', 'Starting remote runtime...')
|
||||
# Prepare the request body for the /start endpoint
|
||||
command = get_action_execution_server_startup_command(
|
||||
server_port=self.port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
self.log('debug', f'Action execution server command: {command}')
|
||||
|
||||
environment = {}
|
||||
if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true':
|
||||
environment['DEBUG'] = 'true'
|
||||
environment.update(self.config.sandbox.runtime_startup_env_vars)
|
||||
|
||||
start_request = {
|
||||
'image': self.container_image,
|
||||
'command': command,
|
||||
@@ -242,10 +258,16 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
'session_id': self.sid,
|
||||
'resource_factor': self.config.sandbox.remote_runtime_resource_factor,
|
||||
}
|
||||
|
||||
if self.config.sandbox.remote_runtime_class == 'sysbox':
|
||||
start_request['runtime_class'] = 'sysbox-runc'
|
||||
self.log('debug', 'Using sysbox runtime class')
|
||||
else:
|
||||
self.log('debug', 'Using default (gvisor) runtime class')
|
||||
# We ignore other runtime classes for now, because both None and 'gvisor' map to 'gvisor'
|
||||
|
||||
self.log('debug', f'Sending start request with parameters: {start_request}')
|
||||
|
||||
# Start the sandbox using the /start endpoint
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
@@ -255,8 +277,8 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
)
|
||||
self._parse_runtime_response(response)
|
||||
self.log(
|
||||
'debug',
|
||||
f'Runtime started. URL: {self.runtime_url}',
|
||||
'info',
|
||||
f'Runtime started successfully. Runtime ID: {self.runtime_id}, URL: {self.runtime_url}',
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
self.log('error', f'Unable to start runtime: {str(e)}')
|
||||
@@ -269,15 +291,27 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
3. Poll for the runtime to be ready
|
||||
4. Update env vars
|
||||
"""
|
||||
self.log('debug', f'Resuming paused runtime with ID: {self.runtime_id}')
|
||||
self.send_status_message('STATUS$STARTING_RUNTIME')
|
||||
self._send_runtime_api_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/resume',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
self._wait_until_alive()
|
||||
self.setup_initial_env()
|
||||
self.log('debug', 'Runtime resumed.')
|
||||
|
||||
try:
|
||||
self.log('debug', 'Sending resume request to runtime API')
|
||||
self._send_runtime_api_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/resume',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
|
||||
self.log('debug', 'Waiting for resumed runtime to become alive')
|
||||
self._wait_until_alive()
|
||||
|
||||
self.log('debug', 'Setting up initial environment for resumed runtime')
|
||||
self.setup_initial_env()
|
||||
|
||||
self.log('info', f'Runtime with ID {self.runtime_id} resumed successfully')
|
||||
except Exception as e:
|
||||
self.log('error', f'Failed to resume runtime: {str(e)}')
|
||||
raise
|
||||
|
||||
def _parse_runtime_response(self, response: httpx.Response):
|
||||
start_response = response.json()
|
||||
@@ -324,53 +358,62 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
return retry_decorator(self._wait_until_alive_impl)()
|
||||
|
||||
def _wait_until_alive_impl(self):
|
||||
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
|
||||
self.log('debug', f'Checking if runtime is alive at url: {self.runtime_url}')
|
||||
runtime_info_response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
|
||||
)
|
||||
runtime_data = runtime_info_response.json()
|
||||
assert 'runtime_id' in runtime_data
|
||||
assert runtime_data['runtime_id'] == self.runtime_id
|
||||
assert 'pod_status' in runtime_data
|
||||
self.log('debug', f'Runtime info response: {runtime_data}')
|
||||
|
||||
assert 'runtime_id' in runtime_data, "Missing runtime_id in response"
|
||||
assert runtime_data['runtime_id'] == self.runtime_id, f"Runtime ID mismatch: {runtime_data['runtime_id']} != {self.runtime_id}"
|
||||
assert 'pod_status' in runtime_data, "Missing pod_status in response"
|
||||
|
||||
pod_status = runtime_data['pod_status'].lower()
|
||||
self.log('debug', f'Pod status: {pod_status}')
|
||||
|
||||
restart_count = runtime_data.get('restart_count', 0)
|
||||
if restart_count != 0:
|
||||
restart_reasons = runtime_data.get('restart_reasons')
|
||||
self.log(
|
||||
'debug', f'Pod restarts: {restart_count}, reasons: {restart_reasons}'
|
||||
'warning', f'Pod has restarted {restart_count} times, reasons: {restart_reasons}'
|
||||
)
|
||||
|
||||
# FIXME: We should fix it at the backend of /start endpoint, make sure
|
||||
# the pod is created before returning the response.
|
||||
# Retry a period of time to give the cluster time to start the pod
|
||||
if pod_status == 'ready':
|
||||
self.log('debug', 'Pod status is ready, checking if action execution server is alive')
|
||||
try:
|
||||
self.check_if_alive()
|
||||
self.log('info', 'Runtime is fully alive and ready to accept commands')
|
||||
return
|
||||
except httpx.HTTPError as e:
|
||||
self.log(
|
||||
'warning',
|
||||
f"Runtime /alive failed, but pod says it's ready: {str(e)}",
|
||||
f"Runtime /alive check failed, but pod says it's ready: {str(e)}",
|
||||
)
|
||||
raise AgentRuntimeNotReadyError(
|
||||
f'Runtime /alive failed to respond with 200: {str(e)}'
|
||||
)
|
||||
return
|
||||
elif (
|
||||
pod_status == 'not found'
|
||||
or pod_status == 'pending'
|
||||
or pod_status == 'running'
|
||||
): # nb: Running is not yet Ready
|
||||
self.log('debug', f'Pod is in transitional state: {pod_status}')
|
||||
raise AgentRuntimeNotReadyError(
|
||||
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
|
||||
)
|
||||
elif pod_status in ('failed', 'unknown', 'crashloopbackoff'):
|
||||
if pod_status == 'crashloopbackoff':
|
||||
self.log('error', f'Pod is in CrashLoopBackOff state. Restart count: {restart_count}')
|
||||
raise AgentRuntimeUnavailableError(
|
||||
'Runtime crashed and is being restarted, potentially due to memory usage. Please try again.'
|
||||
)
|
||||
else:
|
||||
self.log('error', f'Pod is in failed state: {pod_status}')
|
||||
raise AgentRuntimeUnavailableError(
|
||||
f'Runtime is unavailable (status: {pod_status}). Please try again.'
|
||||
)
|
||||
@@ -385,11 +428,18 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
raise AgentRuntimeNotReadyError()
|
||||
|
||||
def close(self):
|
||||
self.log('debug', f'Closing remote runtime with ID: {self.runtime_id}')
|
||||
|
||||
if self.attach_to_existing:
|
||||
self.log('debug', 'Runtime was attached to existing instance, not stopping it')
|
||||
super().close()
|
||||
return
|
||||
|
||||
if self.config.sandbox.keep_runtime_alive:
|
||||
self.log('debug', 'keep_runtime_alive is set, not stopping the runtime')
|
||||
|
||||
if self.config.sandbox.pause_closed_runtimes:
|
||||
self.log('debug', 'pause_closed_runtimes is set, pausing the runtime')
|
||||
try:
|
||||
if not self._runtime_closed:
|
||||
self._send_runtime_api_request(
|
||||
@@ -397,12 +447,14 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/pause',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
self.log('debug', 'Runtime paused.')
|
||||
self.log('info', f'Runtime with ID {self.runtime_id} paused successfully')
|
||||
except Exception as e:
|
||||
self.log('error', f'Unable to pause runtime: {str(e)}')
|
||||
raise e
|
||||
super().close()
|
||||
return
|
||||
|
||||
self.log('debug', 'Stopping the runtime')
|
||||
try:
|
||||
if not self._runtime_closed:
|
||||
self._send_runtime_api_request(
|
||||
@@ -410,7 +462,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/stop',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
self.log('debug', 'Runtime stopped.')
|
||||
self.log('info', f'Runtime with ID {self.runtime_id} stopped successfully')
|
||||
except Exception as e:
|
||||
self.log('error', f'Unable to stop runtime: {str(e)}')
|
||||
raise e
|
||||
@@ -418,15 +470,21 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
super().close()
|
||||
|
||||
def _send_runtime_api_request(self, method, url, **kwargs):
|
||||
self.log('debug', f'Sending {method} request to {url} with kwargs: {kwargs}')
|
||||
try:
|
||||
kwargs['timeout'] = self.config.sandbox.remote_runtime_api_timeout
|
||||
return send_request(self.session, method, url, **kwargs)
|
||||
response = send_request(self.session, method, url, **kwargs)
|
||||
self.log('debug', f'Received response from {url}: status={response.status_code}')
|
||||
return response
|
||||
except httpx.TimeoutException:
|
||||
self.log(
|
||||
'error',
|
||||
f'No response received within the timeout period for url: {url}',
|
||||
)
|
||||
raise
|
||||
except httpx.HTTPError as e:
|
||||
self.log('error', f'HTTP error when calling {url}: {str(e)}')
|
||||
raise
|
||||
|
||||
def _send_action_server_request(self, method, url, **kwargs):
|
||||
if not self.config.sandbox.remote_runtime_enable_retries:
|
||||
|
||||
@@ -45,7 +45,14 @@ def create_provider_tokens_object(
|
||||
async def connect(connection_id: str, environ):
|
||||
logger.info(f'sio:connect: {connection_id}')
|
||||
query_params = parse_qs(environ.get('QUERY_STRING', ''))
|
||||
latest_event_id = int(query_params.get('latest_event_id', [-1])[0])
|
||||
latest_event_id_str = query_params.get('latest_event_id', [-1])[0]
|
||||
try:
|
||||
latest_event_id = int(latest_event_id_str)
|
||||
except ValueError:
|
||||
logger.debug(
|
||||
f'Invalid latest_event_id value: {latest_event_id_str}, defaulting to -1'
|
||||
)
|
||||
latest_event_id = -1
|
||||
conversation_id = query_params.get('conversation_id', [None])[0]
|
||||
raw_list = query_params.get('providers_set', [])
|
||||
providers_list = []
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user