mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 532a284d5c | |||
| 43f6104967 | |||
| e249b920ff | |||
| d920a69f69 | |||
| a8ce888981 | |||
| e22ddc0dd6 | |||
| c370912f12 |
@@ -30,7 +30,6 @@ body:
|
||||
description: How are you running OpenHands?
|
||||
options:
|
||||
- Docker command in README
|
||||
- GitHub resolver
|
||||
- Development workflow
|
||||
- app.all-hands.dev
|
||||
- Other
|
||||
|
||||
@@ -176,6 +176,7 @@ evaluation/gorilla/data
|
||||
evaluation/toolqa/data
|
||||
evaluation/scienceagentbench/benchmark
|
||||
evaluation/commit0_bench/repos
|
||||
evaluation/visualcodebench/
|
||||
|
||||
# openhands resolver
|
||||
output/
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
|
||||
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.22-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.21-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="https://codecov.io/github/All-Hands-AI/OpenHands?branch=main"><img alt="CodeCov" src="https://img.shields.io/codecov/c/github/All-Hands-AI/OpenHands?style=for-the-badge&color=blue"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
|
||||
<br/>
|
||||
@@ -43,17 +43,17 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio
|
||||
system requirements and more information.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.22-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
@@ -96,7 +96,7 @@ troubleshooting resources, and advanced configuration options.
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
|
||||
through Slack, so this is the best place to start, but we also are happy to have you contact us on Discord or Github:
|
||||
|
||||
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Discord server](https://discord.gg/ESHStjSjD4) - This is a community-run server for general discussion, questions, and feedback.
|
||||
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ workspace_base = "./workspace"
|
||||
#aws_secret_access_key = ""
|
||||
|
||||
# API key to use (For Headless / CLI only - In Web this is overridden by Session Init)
|
||||
api_key = ""
|
||||
api_key = "your-api-key"
|
||||
|
||||
# API base URL (For Headless / CLI only - In Web this is overridden by Session Init)
|
||||
#base_url = ""
|
||||
@@ -195,7 +195,7 @@ model = "gpt-4o"
|
||||
#native_tool_calling = None
|
||||
|
||||
[llm.gpt4o-mini]
|
||||
api_key = ""
|
||||
api_key = "your-api-key"
|
||||
model = "gpt-4o"
|
||||
|
||||
|
||||
|
||||
@@ -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.22-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.21-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -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.22-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.21-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:
|
||||
|
||||
@@ -95,3 +95,7 @@ sandbox_user_id="1001"
|
||||
### Erreurs de port d'utilisation
|
||||
|
||||
Si vous voyez un message d'erreur indiquant que le port est utilisé ou indisponible, essayez de supprimer toutes les containers docker en cours d'exécution (exécutez `docker ps` et `docker rm` des containers concernés) puis ré-exécutez ```make run```
|
||||
|
||||
## Discuter
|
||||
|
||||
Pour d'autres problèmes ou questions rejoignez le [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) ou le [Discord](https://discord.gg/ESHStjSjD4) et demandez!
|
||||
|
||||
@@ -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.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
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.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
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.22-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
@@ -42,7 +42,7 @@ Explorez le code source d'OpenHands sur [GitHub](https://github.com/All-Hands-AI
|
||||
/>
|
||||
</a>
|
||||
<br></br>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw">
|
||||
<img
|
||||
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
|
||||
alt="Join our Slack community"
|
||||
|
||||
@@ -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.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -96,3 +96,7 @@ sandbox_user_id="1001"
|
||||
### 端口使用错误
|
||||
|
||||
如果您遇到端口被占用或不可用的错误提示,可以尝试先用`docker ps`命令列出所有运行中的 Docker 容器,然后使用`docker rm`命令删除相关容器,最后再重新执行```make run```命令。
|
||||
|
||||
## 讨论
|
||||
|
||||
对于其他问题或疑问,请加入 [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) 或 [Discord](https://discord.gg/ESHStjSjD4) 提问!
|
||||
|
||||
@@ -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.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -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.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
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.22-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](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)。
|
||||
|
||||
@@ -42,7 +42,7 @@ OpenHands 是一个**自主 AI 软件工程师**,能够执行复杂的工程
|
||||
/>
|
||||
</a>
|
||||
<br></br>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw">
|
||||
<img
|
||||
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
|
||||
alt="Join our Slack community"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
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.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -50,17 +50,17 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.22-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.22
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.21
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.22-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
Generated
+612
-1465
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -22,8 +22,8 @@
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-use": "^17.6.0"
|
||||
},
|
||||
@@ -31,7 +31,7 @@
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.7.3"
|
||||
"typescript": "~5.7.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -8,7 +8,7 @@ function CustomFooter() {
|
||||
<footer className="custom-footer">
|
||||
<div className="footer-content">
|
||||
<div className="footer-icons">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg" target="_blank" rel="noopener noreferrer">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw" target="_blank" rel="noopener noreferrer">
|
||||
<FaSlack />
|
||||
</a>
|
||||
<a href="https://discord.gg/ESHStjSjD4" target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -23,7 +23,7 @@ export function HomepageHeader() {
|
||||
<a href="https://codecov.io/github/All-Hands-AI/OpenHands?branch=main"><img alt="CodeCov" src="https://img.shields.io/codecov/c/github/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" /></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License" /></a>
|
||||
<br/>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community" /></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community" /></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community" /></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits" /></a>
|
||||
<br/>
|
||||
|
||||
@@ -67,11 +67,11 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
|
||||
'<uploaded_files>\n'
|
||||
f'/workspace/{workspace_dir_name}\n'
|
||||
'</uploaded_files>\n'
|
||||
f"I've uploaded a python code repository in the directory {workspace_dir_name}. Consider the following issue description:\n\n"
|
||||
f'<issue_description>\n'
|
||||
f"I've uploaded a python code repository in the directory {workspace_dir_name}. Consider the following PR description:\n\n"
|
||||
f'<pr_description>\n'
|
||||
f'{instance.problem_statement}\n'
|
||||
'</issue_description>\n\n'
|
||||
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <issue_description> are met?\n'
|
||||
'</pr_description>\n\n'
|
||||
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <pr_description> are met?\n'
|
||||
"I've already taken care of all changes to any of the test files described in the <pr_description>. This means you DON'T have to modify the testing logic or any of the tests in any way!\n"
|
||||
'Your task is to make the minimal changes to non-tests files in the /workspace directory to ensure the <pr_description> is satisfied.\n'
|
||||
'Follow these steps to resolve the issue:\n'
|
||||
|
||||
@@ -15,25 +15,11 @@ parser.add_argument(
|
||||
action='store_true',
|
||||
help='Show visualization paths for failed instances',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--only-x-instances',
|
||||
action='store_true',
|
||||
help='Only show instances that are ran by X',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
df1 = pd.read_json(args.input_file_1, orient='records', lines=True)
|
||||
df2 = pd.read_json(args.input_file_2, orient='records', lines=True)
|
||||
|
||||
if args.only_x_instances:
|
||||
instance_ids_1 = set(df1['instance_id'].tolist())
|
||||
print(
|
||||
f'Before removing instances not in X={args.input_file_1}: Y={df2.shape[0]} instances'
|
||||
)
|
||||
df2 = df2[df2['instance_id'].isin(instance_ids_1)]
|
||||
print(
|
||||
f'After removing instances not in X={args.input_file_1}: Y={df2.shape[0]} instances'
|
||||
)
|
||||
|
||||
# Get the intersection of the instance_ids
|
||||
df = pd.merge(df1, df2, on='instance_id', how='inner')
|
||||
@@ -100,7 +86,7 @@ repo_diffs = []
|
||||
for repo in all_repos:
|
||||
x_count = len(x_only_by_repo.get(repo, []))
|
||||
y_count = len(y_only_by_repo.get(repo, []))
|
||||
diff = y_count - x_count
|
||||
diff = abs(x_count - y_count)
|
||||
repo_diffs.append((repo, diff))
|
||||
|
||||
# Sort by diff (descending) and then by repo name
|
||||
@@ -120,13 +106,7 @@ for repo, diff in repo_diffs:
|
||||
repo_color = 'red' if is_significant else 'yellow'
|
||||
|
||||
print(f"\n{colored(repo, repo_color, attrs=['bold'])}:")
|
||||
print(
|
||||
colored(
|
||||
f'Difference: {diff} instances! (Larger diff = Y better)',
|
||||
repo_color,
|
||||
attrs=['bold'],
|
||||
)
|
||||
)
|
||||
print(colored(f'Difference: {diff} instances!', repo_color, attrs=['bold']))
|
||||
print(colored(f'X resolved but Y failed: ({len(x_instances)} instances)', 'green'))
|
||||
if x_instances:
|
||||
print(' ' + str(x_instances))
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
import pandas as pd
|
||||
from tqdm import tqdm
|
||||
@@ -21,7 +20,6 @@ output_md_folder = args.oh_output_file.replace('.jsonl', '.viz')
|
||||
print(f'Converting {args.oh_output_file} to markdown files in {output_md_folder}')
|
||||
|
||||
oh_format = pd.read_json(args.oh_output_file, orient='records', lines=True)
|
||||
output_dir = os.path.dirname(args.oh_output_file)
|
||||
|
||||
swebench_eval_file = args.oh_output_file.replace('.jsonl', '.swebench_eval.jsonl')
|
||||
if os.path.exists(swebench_eval_file):
|
||||
@@ -59,172 +57,22 @@ def convert_history_to_str(history):
|
||||
return ret
|
||||
|
||||
|
||||
# Load trajectories for resolved instances
|
||||
def load_completions(instance_id: str):
|
||||
global output_dir
|
||||
glob_path = os.path.join(output_dir, 'llm_completions', instance_id, '*.json')
|
||||
files = sorted(glob(glob_path)) # this is ascending order
|
||||
# pick the last file (last turn)
|
||||
try:
|
||||
file_path = files[-1]
|
||||
except IndexError:
|
||||
# print(f'No files found for instance {instance_id}: files={files}')
|
||||
return None
|
||||
with open(file_path, 'r') as f:
|
||||
result = json.load(f)
|
||||
# create messages
|
||||
messages = result['messages']
|
||||
messages.append(result['response']['choices'][0]['message'])
|
||||
tools = result['kwargs']['tools']
|
||||
return {
|
||||
'messages': messages,
|
||||
'tools': tools,
|
||||
}
|
||||
|
||||
|
||||
def _convert_content(content) -> str:
|
||||
ret = ''
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
assert item['type'] == 'text', 'Only text is supported for now'
|
||||
ret += f'{item["text"]}\n'
|
||||
else:
|
||||
assert isinstance(content, str), 'Only str is supported for now'
|
||||
ret = content
|
||||
return ret
|
||||
|
||||
|
||||
def convert_tool_call_to_string(tool_call: dict) -> str:
|
||||
"""Convert tool call to content in string format."""
|
||||
if 'function' not in tool_call:
|
||||
raise ValueError("Tool call must contain 'function' key.")
|
||||
if 'id' not in tool_call:
|
||||
raise ValueError("Tool call must contain 'id' key.")
|
||||
if 'type' not in tool_call:
|
||||
raise ValueError("Tool call must contain 'type' key.")
|
||||
if tool_call['type'] != 'function':
|
||||
raise ValueError("Tool call type must be 'function'.")
|
||||
|
||||
ret = f"<function={tool_call['function']['name']}>\n"
|
||||
try:
|
||||
args = json.loads(tool_call['function']['arguments'])
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(
|
||||
f"Failed to parse arguments as JSON. Arguments: {tool_call['function']['arguments']}"
|
||||
) from e
|
||||
for param_name, param_value in args.items():
|
||||
is_multiline = isinstance(param_value, str) and '\n' in param_value
|
||||
ret += f'<parameter={param_name}>'
|
||||
if is_multiline:
|
||||
ret += '\n'
|
||||
ret += f'{param_value}'
|
||||
if is_multiline:
|
||||
ret += '\n'
|
||||
ret += '</parameter>\n'
|
||||
ret += '</function>'
|
||||
return ret
|
||||
|
||||
|
||||
def format_traj(traj, first_n_turns=None, last_n_turns=None) -> str:
|
||||
output = ''
|
||||
system_message = None
|
||||
|
||||
# Handle system message if present
|
||||
if traj[0]['role'] == 'system':
|
||||
system_message = traj[0]
|
||||
traj = traj[1:]
|
||||
content = _convert_content(system_message['content'])
|
||||
output += "*** System Message that describes the assistant's behavior ***\n"
|
||||
output += f'{content}\n'
|
||||
|
||||
# Merge consecutive user messages first
|
||||
merged_traj = []
|
||||
current_messages = []
|
||||
|
||||
n_turns = len(traj)
|
||||
for i, message in enumerate(traj):
|
||||
# Skip this message if...
|
||||
if (
|
||||
# Case 1: first_n_turns specified and we're past it
|
||||
(first_n_turns is not None and i >= first_n_turns and last_n_turns is None)
|
||||
or
|
||||
# Case 2: last_n_turns specified and we're before it
|
||||
(
|
||||
last_n_turns is not None
|
||||
and i < n_turns - last_n_turns
|
||||
and first_n_turns is None
|
||||
)
|
||||
or
|
||||
# Case 3: both specified and we're in the middle section
|
||||
(
|
||||
first_n_turns is not None
|
||||
and last_n_turns is not None
|
||||
and i >= first_n_turns
|
||||
and i < n_turns - last_n_turns
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
if message['role'] == 'user':
|
||||
current_messages.append(message)
|
||||
else:
|
||||
if current_messages:
|
||||
# Merge all accumulated user messages into one
|
||||
merged_content = '\n'.join(
|
||||
_convert_content(msg['content']) for msg in current_messages
|
||||
)
|
||||
merged_traj.append({'role': 'user', 'content': merged_content})
|
||||
current_messages = []
|
||||
merged_traj.append(message)
|
||||
|
||||
# Don't forget to handle any remaining user messages
|
||||
if current_messages:
|
||||
merged_content = '\n'.join(
|
||||
_convert_content(msg['content']) for msg in current_messages
|
||||
)
|
||||
merged_traj.append({'role': 'user', 'content': merged_content})
|
||||
|
||||
# Now process the merged trajectory
|
||||
for i, message in enumerate(merged_traj):
|
||||
role, content = message['role'], message['content']
|
||||
content = _convert_content(content) if isinstance(content, list) else content
|
||||
turn_id = i // 2 + 1
|
||||
output += '-' * 100 + '\n'
|
||||
output += f'*** Turn {turn_id} - {role.upper() if role != "tool" else "TOOL EXECUTION RESULT"} ***\n'
|
||||
|
||||
if role == 'user':
|
||||
output += f'{content}\n'
|
||||
elif role == 'tool':
|
||||
output += f'{content}\n'
|
||||
elif role == 'assistant':
|
||||
output += f'{content}\n'
|
||||
if (
|
||||
'tool_calls' in message
|
||||
and message['tool_calls'] is not None
|
||||
and len(message['tool_calls']) > 0
|
||||
):
|
||||
for toolcall_id, tool_call in enumerate(message['tool_calls']):
|
||||
output += f'### Tool Call {toolcall_id}\n'
|
||||
output += f'{convert_tool_call_to_string(tool_call)}\n'
|
||||
else:
|
||||
raise ValueError(f'Unexpected role: {role}')
|
||||
|
||||
output += '-' * 100 + '\n'
|
||||
return output
|
||||
|
||||
|
||||
def write_row_to_md_file(row, instance_id_to_test_result):
|
||||
if 'git_patch' in row:
|
||||
model_patch = row['git_patch']
|
||||
elif 'test_result' in row and 'git_patch' in row['test_result']:
|
||||
model_patch = row['test_result']['git_patch']
|
||||
else:
|
||||
print(f'Row {row} does not have a git_patch')
|
||||
return
|
||||
raise ValueError(f'Row {row} does not have a git_patch')
|
||||
|
||||
test_output = None
|
||||
# Use result from output.jsonl FIRST if available.
|
||||
if 'report' in row and row['report'] is not None:
|
||||
if row['instance_id'] in instance_id_to_test_result:
|
||||
report = instance_id_to_test_result[row['instance_id']].get('report', {})
|
||||
resolved = report.get('resolved', False)
|
||||
test_output = instance_id_to_test_result[row['instance_id']].get(
|
||||
'test_output', None
|
||||
)
|
||||
elif 'report' in row and row['report'] is not None:
|
||||
if not isinstance(row['report'], dict):
|
||||
resolved = None
|
||||
print(
|
||||
@@ -232,12 +80,6 @@ def write_row_to_md_file(row, instance_id_to_test_result):
|
||||
)
|
||||
else:
|
||||
resolved = row['report'].get('resolved', False)
|
||||
elif row['instance_id'] in instance_id_to_test_result:
|
||||
report = instance_id_to_test_result[row['instance_id']].get('report', {})
|
||||
resolved = report.get('resolved', False)
|
||||
test_output = instance_id_to_test_result[row['instance_id']].get(
|
||||
'test_output', None
|
||||
)
|
||||
else:
|
||||
resolved = None
|
||||
|
||||
@@ -246,8 +88,6 @@ def write_row_to_md_file(row, instance_id_to_test_result):
|
||||
os.makedirs(output_md_folder, exist_ok=True)
|
||||
filepath = os.path.join(output_md_folder, filename)
|
||||
|
||||
completions = load_completions(instance_id)
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(f'# {instance_id} (resolved: {resolved})\n')
|
||||
|
||||
@@ -257,12 +97,7 @@ def write_row_to_md_file(row, instance_id_to_test_result):
|
||||
f.write(json.dumps(row['metadata'], indent=2))
|
||||
f.write('\n```\n')
|
||||
|
||||
# Completion
|
||||
if completions is not None:
|
||||
f.write('## Completion\n')
|
||||
traj = completions['messages']
|
||||
f.write(format_traj(traj))
|
||||
|
||||
# Trajectory
|
||||
f.write('## History\n')
|
||||
f.write(convert_history_to_str(row['history']))
|
||||
|
||||
|
||||
@@ -207,13 +207,12 @@ with open(args.input_file, 'r') as infile:
|
||||
for line in tqdm(infile, desc='Checking for changes'):
|
||||
data = json.loads(line)
|
||||
instance_id = data['instance_id']
|
||||
current_report = data.get('report', {})
|
||||
new_report = instance_id_to_status[
|
||||
instance_id
|
||||
] # if no report, it's not resolved
|
||||
if current_report != new_report:
|
||||
needs_update = True
|
||||
break
|
||||
if instance_id in instance_id_to_status:
|
||||
current_report = data.get('report', {})
|
||||
new_report = instance_id_to_status[instance_id]
|
||||
if current_report != new_report:
|
||||
needs_update = True
|
||||
break
|
||||
|
||||
if not needs_update:
|
||||
print('No updates detected. Skipping file update.')
|
||||
@@ -235,5 +234,6 @@ with open(args.input_file + '.bak', 'r') as infile, open(
|
||||
for line in tqdm(infile, desc='Updating output file'):
|
||||
data = json.loads(line)
|
||||
instance_id = data['instance_id']
|
||||
data['report'] = instance_id_to_status[instance_id]
|
||||
if instance_id in instance_id_to_status:
|
||||
data['report'] = instance_id_to_status[instance_id]
|
||||
outfile.write(json.dumps(data) + '\n')
|
||||
|
||||
@@ -76,7 +76,7 @@ echo "Running SWE-bench evaluation"
|
||||
echo "=============================================================="
|
||||
|
||||
RUN_ID=$(date +"%Y%m%d_%H%M%S")
|
||||
N_PROCESS=4
|
||||
N_PROCESS=16
|
||||
|
||||
if [ -z "$INSTANCE_ID" ]; then
|
||||
echo "Running SWE-bench evaluation on the whole input file..."
|
||||
@@ -87,7 +87,7 @@ if [ -z "$INSTANCE_ID" ]; then
|
||||
--dataset_name "$DATASET_NAME" \
|
||||
--split "$SPLIT" \
|
||||
--predictions_path $SWEBENCH_FORMAT_JSONL \
|
||||
--timeout 3600 \
|
||||
--timeout 1800 \
|
||||
--cache_level instance \
|
||||
--max_workers $N_PROCESS \
|
||||
--run_id $RUN_ID
|
||||
@@ -133,7 +133,7 @@ else
|
||||
--dataset_name "$DATASET_NAME" \
|
||||
--split "$SPLIT" \
|
||||
--predictions_path $SWEBENCH_FORMAT_JSONL \
|
||||
--timeout 3600 \
|
||||
--timeout 1800 \
|
||||
--instance_ids $INSTANCE_ID \
|
||||
--cache_level instance \
|
||||
--max_workers $N_PROCESS \
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
from difflib import SequenceMatcher
|
||||
from io import BytesIO
|
||||
|
||||
from bs4 import BeautifulSoup, Comment, NavigableString, Tag
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
from colormath.color_conversions import convert_color
|
||||
from colormath.color_diff import delta_e_cie2000
|
||||
from colormath.color_objects import LabColor, sRGBColor
|
||||
from PIL import Image, ImageChops, ImageColor
|
||||
from scipy.optimize import linear_sum_assignment
|
||||
from transformers import CLIPModel, CLIPProcessor
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def calculate_similarity(block1, block2):
|
||||
"""Calculate text similarity between two blocks using SequenceMatcher."""
|
||||
text_similarity = SequenceMatcher(None, block1['text'], block2['text']).ratio()
|
||||
return text_similarity
|
||||
|
||||
|
||||
def adjust_cost_for_context(cost_matrix, consecutive_bonus=1.0, window_size=20):
|
||||
"""Adjust cost matrix by considering context similarity."""
|
||||
if window_size <= 0:
|
||||
return cost_matrix
|
||||
|
||||
n, m = cost_matrix.shape
|
||||
adjusted_cost_matrix = np.copy(cost_matrix)
|
||||
|
||||
for i in range(n):
|
||||
for j in range(m):
|
||||
if adjusted_cost_matrix[i][j] >= -0.5:
|
||||
continue
|
||||
nearby_matrix = cost_matrix[
|
||||
max(0, i - window_size) : min(n, i + window_size + 1),
|
||||
max(0, j - window_size) : min(m, j + window_size + 1),
|
||||
]
|
||||
flattened_array = nearby_matrix.flatten()
|
||||
sorted_array = np.sort(flattened_array)[::-1]
|
||||
sorted_array = np.delete(
|
||||
sorted_array, np.where(sorted_array == cost_matrix[i, j])[0][0]
|
||||
)
|
||||
top_k_elements = sorted_array[-window_size * 2 :]
|
||||
bonus = consecutive_bonus * np.sum(top_k_elements)
|
||||
adjusted_cost_matrix[i][j] += bonus
|
||||
return adjusted_cost_matrix
|
||||
|
||||
|
||||
def create_cost_matrix(A, B):
|
||||
"""Create cost matrix for block matching."""
|
||||
n = len(A)
|
||||
m = len(B)
|
||||
cost_matrix = np.zeros((n, m))
|
||||
for i in range(n):
|
||||
for j in range(m):
|
||||
cost_matrix[i, j] = -calculate_similarity(A[i], B[j])
|
||||
return cost_matrix
|
||||
|
||||
|
||||
def calculate_distance_max_1d(x1, y1, x2, y2):
|
||||
"""Calculate maximum 1D distance between points."""
|
||||
return max(abs(x2 - x1), abs(y2 - y1))
|
||||
|
||||
|
||||
def calculate_ratio(h1, h2):
|
||||
"""Calculate ratio between two heights."""
|
||||
return max(h1, h2) / min(h1, h2)
|
||||
|
||||
|
||||
def rgb_to_lab(rgb):
|
||||
"""Convert RGB color to Lab color space."""
|
||||
rgb_color = sRGBColor(rgb[0], rgb[1], rgb[2], is_upscaled=True)
|
||||
lab_color = convert_color(rgb_color, LabColor)
|
||||
return lab_color
|
||||
|
||||
|
||||
def color_similarity_ciede2000(rgb1, rgb2):
|
||||
"""Calculate color similarity using CIEDE2000 formula."""
|
||||
lab1 = rgb_to_lab(rgb1)
|
||||
lab2 = rgb_to_lab(rgb2)
|
||||
delta_e = delta_e_cie2000(lab1, lab2)
|
||||
similarity = max(0, 1 - (delta_e / 100))
|
||||
return similarity
|
||||
|
||||
|
||||
def merge_blocks_wo_check(block1, block2):
|
||||
"""Merge two blocks without additional checks."""
|
||||
merged_text = block1['text'] + ' ' + block2['text']
|
||||
x_min = min(block1['bbox'][0], block2['bbox'][0])
|
||||
y_min = min(block1['bbox'][1], block2['bbox'][1])
|
||||
x_max = max(
|
||||
block1['bbox'][0] + block1['bbox'][2], block2['bbox'][0] + block2['bbox'][2]
|
||||
)
|
||||
y_max = max(
|
||||
block1['bbox'][1] + block1['bbox'][3], block2['bbox'][1] + block2['bbox'][3]
|
||||
)
|
||||
merged_bbox = (x_min, y_min, x_max - x_min, y_max - y_min)
|
||||
merged_color = tuple(
|
||||
(color1 + color2) // 2
|
||||
for color1, color2 in zip(block1['color'], block2['color'])
|
||||
)
|
||||
return {'text': merged_text, 'bbox': merged_bbox, 'color': merged_color}
|
||||
|
||||
|
||||
def find_maximum_matching(A, B, consecutive_bonus, window_size):
|
||||
"""Find maximum matching between two sets of blocks."""
|
||||
cost_matrix = create_cost_matrix(A, B)
|
||||
cost_matrix = adjust_cost_for_context(cost_matrix, consecutive_bonus, window_size)
|
||||
row_ind, col_ind = linear_sum_assignment(cost_matrix)
|
||||
current_cost = cost_matrix[row_ind, col_ind].tolist()
|
||||
return list(zip(row_ind, col_ind)), current_cost, cost_matrix
|
||||
|
||||
|
||||
def remove_indices(lst, indices):
|
||||
"""Remove indices from list in reverse order."""
|
||||
for index in sorted(indices, reverse=True):
|
||||
if index < len(lst):
|
||||
lst.pop(index)
|
||||
return lst
|
||||
|
||||
|
||||
def merge_blocks_by_list(blocks, merge_list):
|
||||
"""Merge blocks according to merge list."""
|
||||
pop_list = []
|
||||
while merge_list:
|
||||
i = merge_list[0][0]
|
||||
j = merge_list[0][1]
|
||||
blocks[i] = merge_blocks_wo_check(blocks[i], blocks[j])
|
||||
pop_list.append(j)
|
||||
merge_list.pop(0)
|
||||
if merge_list:
|
||||
new_merge_list = []
|
||||
for k in range(len(merge_list)):
|
||||
if (
|
||||
merge_list[k][0] != i
|
||||
and merge_list[k][1] != i
|
||||
and merge_list[k][0] != j
|
||||
and merge_list[k][1] != j
|
||||
):
|
||||
new_merge_list.append(merge_list[k])
|
||||
merge_list = new_merge_list
|
||||
remove_indices(blocks, pop_list)
|
||||
return blocks
|
||||
|
||||
|
||||
def difference_of_means(list1, list2):
|
||||
"""Calculate difference of means between two lists."""
|
||||
counter1 = Counter(list1)
|
||||
counter2 = Counter(list2)
|
||||
|
||||
for element in set(list1) & set(list2):
|
||||
common_count = min(counter1[element], counter2[element])
|
||||
counter1[element] -= common_count
|
||||
counter2[element] -= common_count
|
||||
|
||||
unique_list1 = [item for item in counter1.elements()]
|
||||
unique_list2 = [item for item in counter2.elements()]
|
||||
|
||||
mean_list1 = sum(unique_list1) / len(unique_list1) if unique_list1 else 0
|
||||
mean_list2 = sum(unique_list2) / len(unique_list2) if unique_list2 else 0
|
||||
|
||||
if mean_list1 - mean_list2 > 0:
|
||||
if min(unique_list1) > min(unique_list2):
|
||||
return mean_list1 - mean_list2
|
||||
return 0.0
|
||||
return mean_list1 - mean_list2
|
||||
|
||||
|
||||
def find_possible_merge(A, B, consecutive_bonus, window_size, debug=False):
|
||||
"""Find possible merges between blocks."""
|
||||
merge_bonus = 0.0
|
||||
merge_windows = 1
|
||||
|
||||
def sortFn(value):
|
||||
return value[2]
|
||||
|
||||
while True:
|
||||
A_changed = False
|
||||
B_changed = False
|
||||
|
||||
matching, current_cost, cost_matrix = find_maximum_matching(
|
||||
A, B, merge_bonus, merge_windows
|
||||
)
|
||||
|
||||
if len(A) >= 2:
|
||||
merge_list = []
|
||||
for i in range(len(A) - 1):
|
||||
new_A = deepcopy(A)
|
||||
new_A[i] = merge_blocks_wo_check(new_A[i], new_A[i + 1])
|
||||
new_A.pop(i + 1)
|
||||
updated_matching, updated_cost, _ = find_maximum_matching(
|
||||
new_A, B, merge_bonus, merge_windows
|
||||
)
|
||||
diff = difference_of_means(current_cost, updated_cost)
|
||||
if diff > 0.05:
|
||||
merge_list.append([i, i + 1, diff])
|
||||
|
||||
merge_list.sort(key=sortFn, reverse=True)
|
||||
if merge_list:
|
||||
A_changed = True
|
||||
A = merge_blocks_by_list(A, merge_list)
|
||||
matching, current_cost, cost_matrix = find_maximum_matching(
|
||||
A, B, merge_bonus, merge_windows
|
||||
)
|
||||
|
||||
if len(B) >= 2:
|
||||
merge_list = []
|
||||
for i in range(len(B) - 1):
|
||||
new_B = deepcopy(B)
|
||||
new_B[i] = merge_blocks_wo_check(new_B[i], new_B[i + 1])
|
||||
new_B.pop(i + 1)
|
||||
updated_matching, updated_cost, _ = find_maximum_matching(
|
||||
A, new_B, merge_bonus, merge_windows
|
||||
)
|
||||
diff = difference_of_means(current_cost, updated_cost)
|
||||
if diff > 0.05:
|
||||
merge_list.append([i, i + 1, diff])
|
||||
|
||||
merge_list.sort(key=sortFn, reverse=True)
|
||||
if merge_list:
|
||||
B_changed = True
|
||||
B = merge_blocks_by_list(B, merge_list)
|
||||
matching, current_cost, cost_matrix = find_maximum_matching(
|
||||
A, B, merge_bonus, merge_windows
|
||||
)
|
||||
|
||||
if not A_changed and not B_changed:
|
||||
break
|
||||
|
||||
matching, _, _ = find_maximum_matching(A, B, consecutive_bonus, window_size)
|
||||
return A, B, matching
|
||||
|
||||
|
||||
def merge_blocks_by_bbox(blocks):
|
||||
"""Merge blocks with same bounding box."""
|
||||
merged_blocks = {}
|
||||
for block in blocks:
|
||||
bbox = tuple(block['bbox'])
|
||||
if bbox in merged_blocks:
|
||||
existing_block = merged_blocks[bbox]
|
||||
existing_block['text'] += ' ' + block['text']
|
||||
existing_block['color'] = [
|
||||
(ec + c) / 2 for ec, c in zip(existing_block['color'], block['color'])
|
||||
]
|
||||
else:
|
||||
merged_blocks[bbox] = block
|
||||
return list(merged_blocks.values())
|
||||
|
||||
|
||||
def mask_bounding_boxes_with_inpainting(image, bounding_boxes):
|
||||
"""Mask bounding boxes in image using inpainting."""
|
||||
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
|
||||
mask = np.zeros(image_cv.shape[:2], dtype=np.uint8)
|
||||
height, width = image_cv.shape[:2]
|
||||
|
||||
for bbox in bounding_boxes:
|
||||
x_ratio, y_ratio, w_ratio, h_ratio = bbox
|
||||
x = int(x_ratio * width)
|
||||
y = int(y_ratio * height)
|
||||
w = int(w_ratio * width)
|
||||
h = int(h_ratio * height)
|
||||
mask[y : y + h, x : x + w] = 255
|
||||
|
||||
inpainted_image = cv2.inpaint(image_cv, mask, 3, cv2.INPAINT_TELEA)
|
||||
return Image.fromarray(cv2.cvtColor(inpainted_image, cv2.COLOR_BGR2RGB))
|
||||
|
||||
|
||||
def rescale_and_mask(image, blocks):
|
||||
"""Rescale image and mask blocks."""
|
||||
if blocks:
|
||||
image = mask_bounding_boxes_with_inpainting(image, blocks)
|
||||
|
||||
width, height = image.size
|
||||
if width < height:
|
||||
new_size = (width, width)
|
||||
else:
|
||||
new_size = (height, height)
|
||||
|
||||
return image.resize(new_size, Image.LANCZOS)
|
||||
|
||||
|
||||
def calculate_clip_similarity(image1, image2, blocks1, blocks2):
|
||||
"""Calculate CLIP similarity between two images."""
|
||||
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
|
||||
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
|
||||
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
||||
model = model.to(device)
|
||||
|
||||
# Mask and preprocess images
|
||||
image1_masked = rescale_and_mask(image1, [block['bbox'] for block in blocks1])
|
||||
image2_masked = rescale_and_mask(image2, [block['bbox'] for block in blocks2])
|
||||
inputs = processor(
|
||||
images=[image1_masked, image2_masked], return_tensors='pt', padding=True
|
||||
)
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
# Calculate features and similarity
|
||||
with torch.no_grad():
|
||||
image_features = model.get_image_features(**inputs)
|
||||
image_features1 = image_features[0].unsqueeze(0)
|
||||
image_features2 = image_features[1].unsqueeze(0)
|
||||
image_features1 /= image_features1.norm(dim=-1, keepdim=True)
|
||||
image_features2 /= image_features2.norm(dim=-1, keepdim=True)
|
||||
similarity = (image_features1 @ image_features2.T).item()
|
||||
|
||||
return similarity
|
||||
|
||||
|
||||
def rgb_to_hex(rgb):
|
||||
"""Convert an RGB tuple to hexadecimal format."""
|
||||
return '{:02X}{:02X}{:02X}'.format(*rgb)
|
||||
|
||||
|
||||
class ColorPool:
|
||||
def __init__(self, offset=0):
|
||||
color_values = list(range(10, 251, 16))
|
||||
color_list = [((r + offset) % 256, (g + offset) % 256, (b + offset) % 256)
|
||||
for r in color_values for g in color_values for b in color_values]
|
||||
self.color_pool = [rgb_to_hex(color) for color in color_list]
|
||||
|
||||
def pop_color(self):
|
||||
if self.color_pool:
|
||||
return self.color_pool.pop()
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def process_html_str(html_str, offset=0):
|
||||
"""Process HTML string to assign unique colors to text elements."""
|
||||
soup = BeautifulSoup(html_str, 'html.parser')
|
||||
|
||||
def update_style(element, property_name, value):
|
||||
important_value = f"{value} !important"
|
||||
styles = element.attrs.get('style', '').split(';')
|
||||
updated_styles = [s for s in styles if not s.strip().startswith(property_name) and len(s.strip()) > 0]
|
||||
updated_styles.append(f"{property_name}: {important_value}")
|
||||
element['style'] = '; '.join(updated_styles).strip()
|
||||
|
||||
# Set background color of all elements to transparent white
|
||||
for element in soup.find_all(True):
|
||||
update_style(element, 'background-color', 'rgba(255, 255, 255, 0.0)')
|
||||
|
||||
color_pool = ColorPool(offset)
|
||||
text_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'span', 'a', 'b', 'li',
|
||||
'table', 'td', 'th', 'button', 'footer', 'header', 'figcaption']
|
||||
|
||||
for tag in soup.find_all(text_tags):
|
||||
color = f"#{color_pool.pop_color()}"
|
||||
update_style(tag, 'color', color)
|
||||
update_style(tag, 'opacity', '1.0')
|
||||
|
||||
return str(soup)
|
||||
|
||||
|
||||
def similar(n1, n2):
|
||||
"""Check if two numbers are similar within a threshold."""
|
||||
return abs(n1 - n2) <= 8
|
||||
|
||||
|
||||
def find_different_pixels(image1, image2):
|
||||
"""Find pixels that differ between two images."""
|
||||
if image1.size != image2.size:
|
||||
logger.warning("Images are not the same size")
|
||||
return None
|
||||
|
||||
image1 = image1.convert('RGB')
|
||||
image2 = image2.convert('RGB')
|
||||
pixels1 = image1.load()
|
||||
pixels2 = image2.load()
|
||||
different_pixels = []
|
||||
|
||||
for x in range(image1.size[0]):
|
||||
for y in range(image1.size[1]):
|
||||
r1, g1, b1 = pixels1[x, y]
|
||||
r2, g2, b2 = pixels2[x, y]
|
||||
if similar((r1 + 50) % 256, r2) and similar((g1 + 50) % 256, g2) and similar((b1 + 50) % 256, b2):
|
||||
different_pixels.append((y, x))
|
||||
|
||||
return np.stack(different_pixels) if different_pixels else None
|
||||
|
||||
|
||||
def extract_text_with_color(html_str):
|
||||
"""Extract text and color information from HTML string."""
|
||||
def get_color(tag):
|
||||
if 'style' in tag.attrs:
|
||||
styles = tag['style'].split(';')
|
||||
color_style = [s for s in styles if 'color' in s and 'background-color' not in s]
|
||||
if color_style:
|
||||
color = color_style[-1].split(':')[1].strip().replace(" !important", "")
|
||||
if color[0] == "#":
|
||||
return color
|
||||
else:
|
||||
try:
|
||||
if color.startswith('rgb'):
|
||||
color = tuple(map(int, color[4:-1].split(',')))
|
||||
else:
|
||||
color = ImageColor.getrgb(color)
|
||||
return '#{:02x}{:02x}{:02x}'.format(*color)
|
||||
except ValueError:
|
||||
logger.warning(f"Unable to identify or convert color: {color}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def extract_text_recursive(element, parent_color='#000000'):
|
||||
if isinstance(element, Comment):
|
||||
return None
|
||||
elif isinstance(element, NavigableString):
|
||||
text = element.strip()
|
||||
return (text, parent_color) if text else None
|
||||
elif isinstance(element, Tag):
|
||||
current_color = get_color(element) or parent_color
|
||||
children_texts = filter(None, [extract_text_recursive(child, current_color)
|
||||
for child in element.children])
|
||||
return list(children_texts)
|
||||
|
||||
soup = BeautifulSoup(html_str, 'html.parser')
|
||||
body = soup.body
|
||||
return extract_text_recursive(body) if body else []
|
||||
|
||||
|
||||
def flatten_tree(tree):
|
||||
"""Flatten a nested tree structure into a list."""
|
||||
flat_list = []
|
||||
def flatten(node):
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
flatten(item)
|
||||
else:
|
||||
flat_list.append(node)
|
||||
flatten(tree)
|
||||
return flat_list
|
||||
|
||||
|
||||
def get_blocks_from_image_diff_pixels(image, html_text_color_tree, different_pixels):
|
||||
"""Extract text blocks from image using color differences."""
|
||||
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
|
||||
x_w = image_cv.shape[0]
|
||||
y_w = image_cv.shape[1]
|
||||
|
||||
def hex_to_bgr(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
return rgb[::-1]
|
||||
|
||||
def get_intersect(arr1, arr2):
|
||||
arr1_reshaped = arr1.view([('', arr1.dtype)] * arr1.shape[1])
|
||||
arr2_reshaped = arr2.view([('', arr2.dtype)] * arr2.shape[1])
|
||||
common_rows = np.intersect1d(arr1_reshaped, arr2_reshaped)
|
||||
return common_rows.view(arr1.dtype).reshape(-1, arr1.shape[1])
|
||||
|
||||
blocks = []
|
||||
for item in html_text_color_tree:
|
||||
try:
|
||||
color = np.array(hex_to_bgr(item[1]), dtype="uint8")
|
||||
except:
|
||||
continue
|
||||
|
||||
lower = color - 4
|
||||
upper = color + 4
|
||||
mask = cv2.inRange(image_cv, lower, upper)
|
||||
coords = np.column_stack(np.where(mask > 0))
|
||||
coords = get_intersect(coords, different_pixels)
|
||||
|
||||
if coords.size == 0:
|
||||
continue
|
||||
|
||||
x_min, y_min = np.min(coords, axis=0)
|
||||
x_max, y_max = np.max(coords, axis=0)
|
||||
|
||||
# Get average color from original image
|
||||
color_coords = coords.copy()
|
||||
color_coords = color_coords[color_coords[:, 0] <= x_max]
|
||||
color_coords = color_coords[color_coords[:, 1] <= y_max]
|
||||
colors = [image_cv[x, y] for x, y in color_coords]
|
||||
avg_color = tuple(map(int, np.mean(colors, axis=0)))[::-1] # Convert BGR to RGB
|
||||
|
||||
blocks.append({
|
||||
'text': item[0].lower(),
|
||||
'bbox': (y_min / y_w, x_min / x_w, (y_max - y_min + 1) / y_w, (x_max - x_min + 1) / x_w),
|
||||
'color': avg_color
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def get_blocks_from_html(html_str, image1):
|
||||
"""Extract text blocks from HTML and image."""
|
||||
# Process HTML with two different color offsets
|
||||
html_str_1 = process_html_str(html_str, offset=0)
|
||||
html_str_2 = process_html_str(html_str, offset=50)
|
||||
|
||||
# Render both HTML versions to images
|
||||
# TODO: Screenshot html_str_2
|
||||
filter_color = (255, 0, 0)
|
||||
image2 = Image.new("RGB", image1.size, filter_color)
|
||||
|
||||
|
||||
# Find pixels that differ between the two rendered images
|
||||
different_pixels = find_different_pixels(image1, image2)
|
||||
if different_pixels is None:
|
||||
logger.warning("Unable to get pixels with different colors")
|
||||
return []
|
||||
|
||||
# Extract text and color information from HTML
|
||||
html_text_color_tree = flatten_tree(extract_text_with_color(html_str_1))
|
||||
try:
|
||||
blocks = get_blocks_from_image_diff_pixels(image1, html_text_color_tree, different_pixels)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get blocks: {e}")
|
||||
return []
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def evaluate(task, generated_img):
|
||||
"""Evaluate generated image against reference image using multiple metrics."""
|
||||
# Load reference image
|
||||
post_image = task['post_image']
|
||||
|
||||
# Extract blocks from HTML and images
|
||||
post_blocks = get_blocks_from_html(task['post_html'], post_image)
|
||||
gen_blocks = get_blocks_from_html(task['gen_html'], generated_img)
|
||||
|
||||
print("block details", post_blocks, gen_blocks)
|
||||
if not post_blocks or not gen_blocks:
|
||||
# Fallback to basic CLIP and pixel comparison if no blocks available
|
||||
clip_score = calculate_clip_similarity(post_image, generated_img, [], [])
|
||||
logger.info(f'CLIP similarity score: {clip_score}')
|
||||
|
||||
# Pixel comparison
|
||||
diff = ImageChops.difference(generated_img, post_image)
|
||||
pixel_match = not diff.getbbox()
|
||||
logger.info(
|
||||
f"Pixel difference analysis: {'No difference' if pixel_match else 'Differences found'}"
|
||||
)
|
||||
|
||||
return clip_score > 0.95 or pixel_match
|
||||
|
||||
# Merge blocks with same bounding boxes
|
||||
post_blocks = merge_blocks_by_bbox(post_blocks)
|
||||
gen_blocks = merge_blocks_by_bbox(gen_blocks)
|
||||
|
||||
# Find optimal block matching
|
||||
consecutive_bonus, window_size = 0.1, 1
|
||||
gen_blocks_m, post_blocks_m, matching = find_possible_merge(
|
||||
gen_blocks, deepcopy(post_blocks), consecutive_bonus, window_size
|
||||
)
|
||||
|
||||
# Filter matches with low similarity
|
||||
filtered_matching = []
|
||||
for i, j in matching:
|
||||
text_similarity = calculate_similarity(gen_blocks_m[i], post_blocks_m[j])
|
||||
if text_similarity >= 0.5:
|
||||
filtered_matching.append([i, j, text_similarity])
|
||||
matching = filtered_matching
|
||||
|
||||
if not matching:
|
||||
logger.warning('No matching blocks found')
|
||||
clip_score = calculate_clip_similarity(
|
||||
post_image, generated_img, gen_blocks, post_blocks
|
||||
)
|
||||
return clip_score > 0.95
|
||||
|
||||
# Calculate metrics for matched blocks
|
||||
indices1 = [item[0] for item in matching]
|
||||
indices2 = [item[1] for item in matching]
|
||||
|
||||
# Calculate unmatched areas
|
||||
unmatched_area_1 = sum(
|
||||
block['bbox'][2] * block['bbox'][3]
|
||||
for i, block in enumerate(gen_blocks_m)
|
||||
if i not in indices1
|
||||
)
|
||||
unmatched_area_2 = sum(
|
||||
block['bbox'][2] * block['bbox'][3]
|
||||
for j, block in enumerate(post_blocks_m)
|
||||
if j not in indices2
|
||||
)
|
||||
total_unmatched_area = unmatched_area_1 + unmatched_area_2
|
||||
|
||||
# Calculate metrics for matched blocks
|
||||
matched_areas = []
|
||||
text_scores = []
|
||||
position_scores = []
|
||||
color_scores = []
|
||||
|
||||
for i, j, text_similarity in matching:
|
||||
# Area
|
||||
block_area = (
|
||||
gen_blocks_m[i]['bbox'][2] * gen_blocks_m[i]['bbox'][3]
|
||||
+ post_blocks_m[j]['bbox'][2] * post_blocks_m[j]['bbox'][3]
|
||||
)
|
||||
matched_areas.append(block_area)
|
||||
|
||||
# Position similarity
|
||||
position_similarity = 1 - calculate_distance_max_1d(
|
||||
gen_blocks_m[i]['bbox'][0] + gen_blocks_m[i]['bbox'][2] / 2,
|
||||
gen_blocks_m[i]['bbox'][1] + gen_blocks_m[i]['bbox'][3] / 2,
|
||||
post_blocks_m[j]['bbox'][0] + post_blocks_m[j]['bbox'][2] / 2,
|
||||
post_blocks_m[j]['bbox'][1] + post_blocks_m[j]['bbox'][3] / 2,
|
||||
)
|
||||
|
||||
# Color similarity
|
||||
color_similarity = color_similarity_ciede2000(
|
||||
gen_blocks_m[i]['color'], post_blocks_m[j]['color']
|
||||
)
|
||||
|
||||
text_scores.append(text_similarity)
|
||||
position_scores.append(position_similarity)
|
||||
color_scores.append(color_similarity)
|
||||
|
||||
# Calculate final scores
|
||||
total_area = sum(matched_areas) + total_unmatched_area
|
||||
size_score = sum(matched_areas) / total_area if total_area > 0 else 0
|
||||
text_score = np.mean(text_scores) if text_scores else 0
|
||||
position_score = np.mean(position_scores) if position_scores else 0
|
||||
color_score = np.mean(color_scores) if color_scores else 0
|
||||
clip_score = calculate_clip_similarity(
|
||||
post_image, generated_img, gen_blocks, post_blocks
|
||||
)
|
||||
|
||||
# Combine scores with equal weights
|
||||
final_score = 0.2 * (
|
||||
size_score + text_score + position_score + color_score + clip_score
|
||||
)
|
||||
|
||||
logger.info('Evaluation scores:')
|
||||
logger.info(f'- Size score: {size_score:.3f}')
|
||||
logger.info(f'- Text score: {text_score:.3f}')
|
||||
logger.info(f'- Position score: {position_score:.3f}')
|
||||
logger.info(f'- Color score: {color_score:.3f}')
|
||||
logger.info(f'- CLIP score: {clip_score:.3f}')
|
||||
logger.info(f'- Final score: {final_score:.3f}')
|
||||
|
||||
return final_score > 0.8 # Consider it a match if final score > 80%
|
||||
|
||||
|
||||
def png_to_bytes(png):
|
||||
buffer = BytesIO()
|
||||
png.save(buffer, format='PNG')
|
||||
image_bytes = buffer.getvalue()
|
||||
return image_bytes
|
||||
|
||||
|
||||
def bytes_to_image(image_bytes):
|
||||
"""Convert bytes to a Pillow Image object."""
|
||||
return Image.open(BytesIO(image_bytes))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
first_image = Image.open('./evaluation/visualcodebench/data/1/post.png')
|
||||
image = Image.open('./evaluation/visualcodebench/data/1/prev.png')
|
||||
|
||||
|
||||
html_file = open('./evaluation/visualcodebench/data/1/post/index.html', 'r')
|
||||
first_html = html_file.read()
|
||||
html_file.close()
|
||||
|
||||
html_file = open('./evaluation/visualcodebench/data/1/prev/index.html', 'r')
|
||||
gen_html = html_file.read()
|
||||
html_file.close()
|
||||
|
||||
|
||||
|
||||
sample = {'post_image': first_image, "post_html": first_html, "gen_html": gen_html}
|
||||
|
||||
|
||||
|
||||
evaluate(sample, image)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
import pandas as pd
|
||||
from huggingface_hub import snapshot_download
|
||||
from PIL import PngImagePlugin
|
||||
from tqdm import tqdm
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
REPO_DOWNLOAD_DIR = (
|
||||
'./evaluation/visualcodebench/' # Directory to store the downloaded repository
|
||||
)
|
||||
|
||||
|
||||
def download_repository():
|
||||
"""
|
||||
Download the entire repository from Hugging Face Hub.
|
||||
This function clones the repository into REPO_DOWNLOAD_DIR.
|
||||
"""
|
||||
repo_id = 'rvmalhot/VisualCodeBench'
|
||||
try:
|
||||
logger.info(f"Downloading repository '{repo_id}'...")
|
||||
snapshot_download(
|
||||
repo_id=repo_id,
|
||||
local_dir=REPO_DOWNLOAD_DIR,
|
||||
repo_type='dataset',
|
||||
ignore_patterns=None, # Download all files
|
||||
)
|
||||
logger.info(f"Repository downloaded to '{REPO_DOWNLOAD_DIR}'.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading repository '{repo_id}': {e}")
|
||||
raise e
|
||||
|
||||
|
||||
def format_task_dict(example):
|
||||
instance_id = example['id']
|
||||
prev_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/prev')
|
||||
post_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/post')
|
||||
|
||||
# Check if 'prev' and 'post' directories exist
|
||||
prev_exists = os.path.exists(prev_remote_path)
|
||||
post_exists = os.path.exists(post_remote_path)
|
||||
|
||||
if prev_exists and post_exists:
|
||||
skip = False
|
||||
else:
|
||||
skip = True
|
||||
|
||||
task = {
|
||||
'instance_id': instance_id,
|
||||
'prev_image': example['prev_image'],
|
||||
'post_image': example['post_image'],
|
||||
'changes': example['changes'],
|
||||
'prev_code_files': example['prev_code_files'],
|
||||
'post_code_files': example['post_code_files'],
|
||||
'skip': skip,
|
||||
}
|
||||
|
||||
return task
|
||||
|
||||
|
||||
def prepare_visualcodebench(dataset):
|
||||
logger.info('Processing dataset')
|
||||
dataset_processed = []
|
||||
for example in tqdm(dataset['train']):
|
||||
formatted_example = format_task_dict(example)
|
||||
if formatted_example['skip']:
|
||||
continue
|
||||
del formatted_example['skip']
|
||||
dataset_processed.append(formatted_example)
|
||||
|
||||
return pd.DataFrame(dataset_processed)
|
||||
|
||||
|
||||
def pil_image_to_base64(image: PngImagePlugin.PngImageFile) -> str:
|
||||
"""
|
||||
Converts a PIL image to a Base64-encoded string.
|
||||
|
||||
Parameters:
|
||||
- image (PngImagePlugin.PngImageFile): The PIL image to convert.
|
||||
|
||||
Returns:
|
||||
- str: The Base64-encoded string of the image.
|
||||
"""
|
||||
if not isinstance(image, PngImagePlugin.PngImageFile):
|
||||
raise ValueError(
|
||||
'The provided image is not a PIL.PngImagePlugin.PngImageFile instance.'
|
||||
)
|
||||
|
||||
buffered = BytesIO()
|
||||
image.save(buffered, format='PNG')
|
||||
img_bytes = buffered.getvalue()
|
||||
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
|
||||
base64_with_prefix = f'data:image/png;base64,{img_base64}'
|
||||
return [base64_with_prefix]
|
||||
@@ -0,0 +1,247 @@
|
||||
# FILE: run_infer.py
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from functools import partial
|
||||
|
||||
import pandas as pd
|
||||
from datasets import load_dataset
|
||||
|
||||
# from evaluation.benchmarks.visualcodebench.eval import capture_screenshot
|
||||
from evaluation.benchmarks.visualcodebench.prepare import (
|
||||
REPO_DOWNLOAD_DIR,
|
||||
download_repository,
|
||||
pil_image_to_base64,
|
||||
prepare_visualcodebench,
|
||||
)
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
)
|
||||
from openhands.core.config.utils import parse_arguments
|
||||
from openhands.core.logger import openhands_logger as logger # Import OpenHands logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action.commands import CmdRunAction
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.observation.commands import CmdOutputObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
|
||||
# Define workspace and output directories
|
||||
WORKSPACE_DIR = './workspace'
|
||||
|
||||
FAKE_RESPONSES = {
|
||||
'CodeActAgent': partial(codeact_user_response, encapsulate_solution=True),
|
||||
}
|
||||
|
||||
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='eventstream',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
return config
|
||||
|
||||
|
||||
def initialize_runtime(
|
||||
runtime: Runtime,
|
||||
instance: pd.Series, # this argument is not required
|
||||
):
|
||||
"""Initialize the runtime for the agent.
|
||||
|
||||
This function is called before the runtime is used to run the agent.
|
||||
"""
|
||||
logger.info('-' * 30)
|
||||
logger.info('BEGIN Runtime Initialization Fn')
|
||||
logger.info('-' * 30)
|
||||
workspace_dir_name = instance['instance_id']
|
||||
obs: CmdOutputObservation
|
||||
|
||||
action = CmdRunAction(command='mkdir -p /workspace/{workspace_dir_name}')
|
||||
action.timeout = 600
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert_and_raise(
|
||||
obs.exit_code == 0,
|
||||
f'Failed to create /workspace/{workspace_dir_name}: {str(obs)}',
|
||||
)
|
||||
|
||||
file_path = REPO_DOWNLOAD_DIR + f'data/{workspace_dir_name}/prev/index.html'
|
||||
runtime.copy_to(file_path, f'/workspace/{workspace_dir_name}')
|
||||
logger.info(f'Copied code file for instance {workspace_dir_name}')
|
||||
|
||||
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
|
||||
action.timeout = 600
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert_and_raise(
|
||||
obs.exit_code == 0,
|
||||
f'Failed to cd to /workspace/{workspace_dir_name}: {str(obs)}',
|
||||
)
|
||||
|
||||
logger.info('-' * 30)
|
||||
logger.info('END Runtime Initialization Fn')
|
||||
logger.info('-' * 30)
|
||||
|
||||
|
||||
def complete_runtime(
|
||||
runtime: Runtime,
|
||||
instance: pd.Series, # this argument is not required, but it is used to get the workspace_dir_name
|
||||
) -> str:
|
||||
# TODO: extract edited HTML file from agent workspace
|
||||
# temp_zip = runtime.copy_from(f'/workspace/{instance.instance_id}')
|
||||
# file_name = f'/workspace/{instance.instance_id}/index.html'
|
||||
# with zipfile.ZipFile(temp_zip, 'r') as zip_ref:
|
||||
# if file_name in zip_ref.namelist():
|
||||
# with zip_ref.open(file_name) as file:
|
||||
# file_content = file.read().decode('utf-8') # Decode bytes to string
|
||||
# else:
|
||||
# raise FileNotFoundError(f"'{file_name}' not found in the ZIP archive.")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_folder = REPO_DOWNLOAD_DIR + f'data/{instance.instance_id}/post/'
|
||||
shutil.copytree(src_folder, tmpdir, dirs_exist_ok=True)
|
||||
|
||||
# image = capture_screenshot(tmpdir)
|
||||
# if image is not None:
|
||||
# shutil.copy(os.path.join(tmpdir, 'final_screenshot.png'), REPO_DOWNLOAD_DIR)
|
||||
|
||||
|
||||
def process_instance(
|
||||
instance: pd.Series, metadata: EvalMetadata, reset_logger: bool = True
|
||||
):
|
||||
config = get_config(metadata)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
|
||||
reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
|
||||
else:
|
||||
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
|
||||
|
||||
# =============================================
|
||||
# build instruction
|
||||
# =============================================
|
||||
|
||||
# Prepare instruction
|
||||
instruction = (
|
||||
f"Modify the HTML/CSS according to the following instruction:\n\n"
|
||||
f"{instance['changes']}\n\n"
|
||||
)
|
||||
instruction += (
|
||||
'IMPORTANT: You should ONLY interact with the environment provided '
|
||||
'to you AND NEVER ASK FOR HUMAN HELP.\n'
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# create sandbox and run the agent
|
||||
# =============================================
|
||||
|
||||
runtime: Runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
try:
|
||||
initialize_runtime(runtime, instance=instance)
|
||||
|
||||
image_urls = pil_image_to_base64(instance['prev_image'])
|
||||
|
||||
action = MessageAction(content=instruction, image_urls=image_urls)
|
||||
state: State | None = asyncio.run(
|
||||
run_controller(
|
||||
config=config,
|
||||
initial_user_action=action,
|
||||
runtime=runtime,
|
||||
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
|
||||
)
|
||||
)
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# =============================================
|
||||
# result evaluation
|
||||
# =============================================
|
||||
|
||||
return_val = complete_runtime(runtime, instance)
|
||||
logger.info(f'Return value {return_val}')
|
||||
finally:
|
||||
runtime.close()
|
||||
|
||||
# TODO: return EVAL output
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the evaluation."""
|
||||
# args = parse_args()
|
||||
args = parse_arguments()
|
||||
|
||||
logger.info(f"\n{'='*80}\nStarting VisualCodeBench Evaluation\n{'='*80}")
|
||||
logger.info(f'Agent: {args.agent_cls}')
|
||||
logger.info(f'Model: {args.llm_config}')
|
||||
logger.info(f'Max iterations: {args.max_iterations}')
|
||||
logger.info(f'Eval limit: {args.eval_n_limit}')
|
||||
logger.info(f'Num workers: {args.eval_num_workers}\n')
|
||||
logger.info(f'Eval output: {args.eval_output_dir}\n')
|
||||
|
||||
# Step 1: Download the entire repository once
|
||||
logger.info('Downloading repository...')
|
||||
download_repository()
|
||||
|
||||
# Step 2: Load Dataset
|
||||
logger.info('Loading dataset...')
|
||||
dataset = load_dataset(REPO_DOWNLOAD_DIR)
|
||||
|
||||
# Step 3: Prepare dataset
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
if llm_config is None:
|
||||
logger.error(f'Could not find LLM config: {args.llm_config}')
|
||||
raise ValueError(f'Could not find LLM config: {args.llm_config}')
|
||||
|
||||
metadata = make_metadata(
|
||||
llm_config,
|
||||
'VisualCodeBench',
|
||||
args.agent_cls,
|
||||
args.max_iterations,
|
||||
args.eval_note,
|
||||
'evaluation/output/',
|
||||
)
|
||||
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
dataset = prepare_visualcodebench(dataset)
|
||||
instances = prepare_dataset(dataset, output_file, eval_n_limit=args.eval_n_limit)
|
||||
|
||||
# Step 4: Run eval
|
||||
run_evaluation(
|
||||
instances, metadata, output_file, args.eval_num_workers, process_instance
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
source "evaluation/utils/version_control.sh"
|
||||
|
||||
# Check if required arguments are provided
|
||||
if [ "$#" -lt 4 ]; then
|
||||
echo "Usage: $0 [model_config] [commit_hash] [agent_cls] [eval_limit] [num_workers]"
|
||||
echo "Example: $0 llm.eval_gpt_4o_mini HEAD CodeActAgent 5 1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MODEL_CONFIG=$1
|
||||
COMMIT_HASH=$2
|
||||
AGENT_CLS=$3
|
||||
EVAL_LIMIT=$4
|
||||
NUM_WORKERS=${5:-1} # Default to 1 worker if not specified
|
||||
|
||||
# Checkout the specified commit
|
||||
checkout_eval_branch
|
||||
|
||||
if [ -z "$AGENT" ]; then
|
||||
echo "Agent not specified, use default CodeActAgent"
|
||||
AGENT="CodeActAgent"
|
||||
fi
|
||||
|
||||
get_openhands_version
|
||||
|
||||
echo "AGENT: $AGENT"
|
||||
echo "OPENHANDS_VERSION: $OPENHANDS_VERSION"
|
||||
echo "MODEL_CONFIG: $MODEL_CONFIG"
|
||||
|
||||
COMMAND="export PYTHONPATH=evaluation/benchmarks/visualcodebench:\$PYTHONPATH && poetry run python evaluation/benchmarks/visualcodebench/run_infer.py \
|
||||
--agent-cls $AGENT \
|
||||
--llm-config $MODEL_CONFIG \
|
||||
--max-iterations 5 \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--eval-note $OPENHANDS_VERSION" \
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
@@ -0,0 +1,167 @@
|
||||
import http
|
||||
import os
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageChops
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def get_free_port():
|
||||
"""Find a free port to run the HTTP server."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def start_http_server(tmpdir):
|
||||
port = get_free_port()
|
||||
|
||||
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def translate_path(self, path):
|
||||
# Serve files from the specified directory instead of the current working directory
|
||||
path = super().translate_path(path)
|
||||
relative_path = os.path.relpath(path, os.getcwd())
|
||||
return os.path.join(tmpdir, relative_path)
|
||||
|
||||
handler = CustomHTTPRequestHandler
|
||||
server = socketserver.TCPServer(('', port), handler)
|
||||
return server, port
|
||||
|
||||
|
||||
def capture_screenshot(tmpdir):
|
||||
server, port = start_http_server(tmpdir)
|
||||
server_thread = threading.Thread(target=server.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
time.sleep(10)
|
||||
|
||||
image = None
|
||||
try:
|
||||
server_url = f'http://localhost:{port}/'
|
||||
|
||||
if not is_server_reachable(server_url):
|
||||
raise RuntimeError(f'Server not reachable at {server_url}')
|
||||
|
||||
screenshot_path = os.path.join(tmpdir, 'final_screenshot.png')
|
||||
capture_screenshot_playwright(server_url, screenshot_path)
|
||||
image = Image.open(screenshot_path)
|
||||
image.load()
|
||||
finally:
|
||||
# Shut down the server and clean up
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def is_server_reachable(url):
|
||||
"""
|
||||
Check if the local server is reachable.
|
||||
"""
|
||||
try:
|
||||
response = requests.get(url, timeout=5) # Set a 5-second timeout
|
||||
if response.status_code == 200:
|
||||
logger.info(f'Server is reachable at {url}')
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f'Server responded with status code {response.status_code} at {url}'
|
||||
)
|
||||
return False
|
||||
except requests.ConnectionError as e:
|
||||
logger.error(f'Failed to connect to server at {url}: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def capture_screenshot_playwright(url, screenshot_path):
|
||||
"""Capture a screenshot of the given URL using Playwright."""
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
logger.info('Launching browser...')
|
||||
browser = p.chromium.launch(timeout=10000) # 10 seconds for browser launch
|
||||
|
||||
logger.info('Creating a new page...')
|
||||
page = browser.new_page()
|
||||
|
||||
logger.info(f'Navigating to URL: {url}')
|
||||
try:
|
||||
page.goto(url, timeout=60 * 1000) # Set timeout to 5 seconds
|
||||
logger.info('Page navigation completed.')
|
||||
except Exception as e:
|
||||
logger.warning(f'Page navigation timed out. {e}. Continuing...')
|
||||
|
||||
logger.info('Waiting for network to be idle...')
|
||||
try:
|
||||
page.wait_for_load_state(
|
||||
'networkidle', timeout=60 * 1000
|
||||
) # Set timeout to 5 seconds
|
||||
logger.info('Page load state reached.')
|
||||
except Exception as e:
|
||||
logger.warning(f'Page load state timed out. {e}. Continuing...')
|
||||
|
||||
logger.info('Capturing screenshot...')
|
||||
page.screenshot(
|
||||
path=screenshot_path, full_page=True
|
||||
) # Capture full page screenshot
|
||||
|
||||
logger.info(f'Screenshot saved to {screenshot_path}')
|
||||
browser.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Error capturing screenshot with Playwright: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def evaluate(task, screenshot_path):
|
||||
"""Compare generated screenshot with post_image using CLIP score."""
|
||||
try:
|
||||
import torch
|
||||
from transformers import CLIPModel, CLIPProcessor
|
||||
|
||||
# Load CLIP model and processor
|
||||
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
|
||||
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
|
||||
|
||||
# Load images
|
||||
post_image = Image.open(BytesIO(task['post_image']))
|
||||
generated_img = Image.open(screenshot_path)
|
||||
|
||||
# Process images
|
||||
inputs = processor(
|
||||
images=[post_image, generated_img], return_tensors='pt', padding=True
|
||||
)
|
||||
|
||||
# Get image features
|
||||
image_features = model.get_image_features(**inputs)
|
||||
|
||||
# Calculate cosine similarity
|
||||
similarity = torch.nn.functional.cosine_similarity(
|
||||
image_features[0].unsqueeze(0), image_features[1].unsqueeze(0)
|
||||
).item()
|
||||
|
||||
logger.info(f'CLIP similarity score: {similarity}')
|
||||
|
||||
return similarity > 0.95 # Consider it a match if similarity > 95%
|
||||
except Exception as e:
|
||||
logger.error(f'Error in CLIP evaluation: {e}')
|
||||
# Fallback to pixel comparison if CLIP fails
|
||||
try:
|
||||
post_image = Image.open(BytesIO(task['post_image']))
|
||||
generated_img = Image.open(screenshot_path)
|
||||
|
||||
# Compare images directly without converting to bytes
|
||||
diff = ImageChops.difference(generated_img, post_image)
|
||||
logger.info(
|
||||
f"Pixel difference analysis: {'No difference' if not diff.getbbox() else 'Differences found'}"
|
||||
)
|
||||
return not diff.getbbox()
|
||||
except Exception as ex:
|
||||
logger.error(f'Error in fallback evaluation: {ex}')
|
||||
return False
|
||||
@@ -4,22 +4,51 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
|
||||
|
||||
// These tests will now fail because the conversation panel is rendered through a portal
|
||||
// and technically not a child of the Sidebar component.
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
path: "/conversation/:conversationId",
|
||||
Component: () => <Sidebar />,
|
||||
},
|
||||
]);
|
||||
const renderSidebar = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
path: "/conversation/:conversationId",
|
||||
Component: Sidebar,
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSidebar = () =>
|
||||
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
};
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should have the conversation panel open by default",
|
||||
() => {
|
||||
renderSidebar();
|
||||
expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should toggle the conversation panel",
|
||||
async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const projectPanelButton = screen.getByTestId(
|
||||
"toggle-conversation-panel",
|
||||
);
|
||||
|
||||
await user.click(projectPanelButton);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-panel"),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
describe("Settings", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
@@ -47,12 +76,35 @@ describe("Sidebar", () => {
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
// the actual values are falsey (null or "") but we're checking for undefined
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: undefined,
|
||||
security_analyzer: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should send all settings data when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const menu = screen.getByTestId("account-settings-context-menu");
|
||||
const accountSettingsButton = within(menu).getByTestId(
|
||||
"account-settings-button",
|
||||
);
|
||||
await user.click(accountSettingsButton);
|
||||
|
||||
const accountSettingsModal = screen.getByTestId("account-settings-form");
|
||||
const saveButton =
|
||||
within(accountSettingsModal).getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
llm_api_key: undefined, // null or undefined
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,15 +139,9 @@ describe("Sidebar", () => {
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
github_token: "new-token",
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
language: "no",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
llm_api_key: undefined, // null or undefined
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,40 +169,11 @@ describe("Sidebar", () => {
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings Modal", () => {
|
||||
it("should open the settings modal if the user clicks the settings button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open the settings modal if GET /settings fails", async () => {
|
||||
vi.spyOn(OpenHands, "getSettings").mockRejectedValue(
|
||||
new Error("Failed to fetch settings"),
|
||||
);
|
||||
|
||||
renderSidebar();
|
||||
|
||||
const settingsModal = await screen.findByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("WaitlistModal", () => {
|
||||
});
|
||||
|
||||
it("should render a tos checkbox that is unchecked by default", () => {
|
||||
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
|
||||
render(<WaitlistModal ghToken={null} githubAuthUrl={null} />);
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
|
||||
expect(checkbox).not.toBeChecked();
|
||||
@@ -22,7 +22,7 @@ describe("WaitlistModal", () => {
|
||||
|
||||
it("should only enable the GitHub button if the tos checkbox is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
|
||||
render(<WaitlistModal ghToken={null} githubAuthUrl={null} />);
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const button = screen.getByRole("button", { name: "Connect to GitHub" });
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("WaitlistModal", () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl="mock-url" />);
|
||||
render(<WaitlistModal ghToken={null} githubAuthUrl="mock-url" />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await user.click(checkbox);
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
describe("AccountSettingsModal", () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should send all settings data when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const languageInput = screen.getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput = screen.getByTestId("github-token-input");
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "no",
|
||||
github_token: "new-token",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a checkmark and not the input if the github token is set", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const checkmark = screen.queryByTestId("github-token-set-checkmark");
|
||||
const input = screen.queryByTestId("github-token-input");
|
||||
|
||||
expect(checkmark).toBeInTheDocument();
|
||||
expect(input).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an unset github token property when pressing disconnect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const disconnectButton = await screen.findByTestId("disconnect-github");
|
||||
await user.click(disconnectButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
unset_github_token: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not unset the github token when changing the language", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const languageInput = screen.getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "no",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { getCachedConfig } from "../../src/utils/storage";
|
||||
|
||||
describe("getCachedConfig", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all instances and calls to constructor and all methods
|
||||
Storage.prototype.getItem = vi.fn();
|
||||
});
|
||||
|
||||
it("should return an empty object when local storage is null or undefined", () => {
|
||||
(Storage.prototype.getItem as Mock).mockReturnValue(null);
|
||||
expect(getCachedConfig()).toEqual({});
|
||||
|
||||
(Storage.prototype.getItem as Mock).mockReturnValue(undefined);
|
||||
expect(getCachedConfig()).toEqual({});
|
||||
});
|
||||
|
||||
it("should return an empty object when local storage has invalid JSON", () => {
|
||||
(Storage.prototype.getItem as Mock).mockReturnValue("invalid JSON");
|
||||
expect(getCachedConfig()).toEqual({});
|
||||
});
|
||||
|
||||
it("should return parsed object when local storage has valid JSON", () => {
|
||||
const validJSON = '{"key":"value"}';
|
||||
(Storage.prototype.getItem as Mock).mockReturnValue(validJSON);
|
||||
expect(getCachedConfig()).toEqual({ key: "value" });
|
||||
});
|
||||
});
|
||||
Generated
+818
-1592
File diff suppressed because it is too large
Load Diff
+21
-21
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.22.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -9,25 +9,25 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@nextui-org/react": "^2.6.11",
|
||||
"@react-router/node": "^7.1.3",
|
||||
"@react-router/serve": "^7.1.3",
|
||||
"@react-router/node": "^7.1.2",
|
||||
"@react-router/serve": "^7.1.2",
|
||||
"@react-types/shared": "^3.27.0",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@tanstack/react-query": "^5.65.1",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@tanstack/react-query": "^5.64.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.0.6",
|
||||
"i18next": "^24.2.2",
|
||||
"framer-motion": "^12.0.1",
|
||||
"i18next": "^24.2.1",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.22",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"isbot": "^5.1.21",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.211.3",
|
||||
"posthog-js": "^1.207.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -36,14 +36,14 @@
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.3",
|
||||
"react-router": "^7.1.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vite": "^6.0.11",
|
||||
"vite": "^5.4.11",
|
||||
"web-vitals": "^3.5.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
@@ -77,23 +77,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@react-router/dev": "^7.1.3",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.65.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.64.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@testing-library/user-event": "^14.6.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^3.0.4",
|
||||
"@vitest/coverage-v8": "^3.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -107,7 +107,7 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.4.3",
|
||||
"lint-staged": "^15.4.1",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
const github = axios.create({
|
||||
baseURL: "https://api.github.com",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
const setAuthTokenHeader = (token: string) => {
|
||||
github.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
};
|
||||
|
||||
const removeAuthTokenHeader = () => {
|
||||
if (github.defaults.headers.common.Authorization) {
|
||||
delete github.defaults.headers.common.Authorization;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if response has attributes to perform refresh
|
||||
*/
|
||||
const canRefresh = (error: unknown): boolean =>
|
||||
!!(
|
||||
error instanceof AxiosError &&
|
||||
error.config &&
|
||||
error.response &&
|
||||
error.response.status
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if the data is a GitHub error response
|
||||
* @param data The data to check
|
||||
* @returns Boolean indicating if the data is a GitHub error response
|
||||
*/
|
||||
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
|
||||
data: T | GitHubErrorReponse | null,
|
||||
): data is GitHubErrorReponse =>
|
||||
!!data && "message" in data && data.message !== undefined;
|
||||
|
||||
// Axios interceptor to handle token refresh
|
||||
const setupAxiosInterceptors = (
|
||||
appMode: string,
|
||||
refreshToken: () => Promise<boolean>,
|
||||
logout: () => void,
|
||||
) => {
|
||||
github.interceptors.response.use(
|
||||
// Pass successful responses through
|
||||
(response) => {
|
||||
const parsedData = response.data;
|
||||
if (isGitHubErrorReponse(parsedData)) {
|
||||
const error = new AxiosError(
|
||||
"Failed",
|
||||
"",
|
||||
response.config,
|
||||
response.request,
|
||||
response,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
// Retry request exactly once if token is expired
|
||||
async (error) => {
|
||||
if (!canRefresh(error)) {
|
||||
return Promise.reject(new Error("Failed to refresh token"));
|
||||
}
|
||||
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Check if the error is due to an expired token
|
||||
if (
|
||||
error.response.status === 401 &&
|
||||
!originalRequest._retry // Prevent infinite retry loops
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
if (appMode === "saas") {
|
||||
try {
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
return await github(originalRequest);
|
||||
}
|
||||
|
||||
logout();
|
||||
return await Promise.reject(new Error("Failed to refresh token"));
|
||||
} catch (refreshError) {
|
||||
// If token refresh fails, evict the user
|
||||
logout();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the error is not due to an expired token, propagate the error
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
github,
|
||||
setAuthTokenHeader,
|
||||
removeAuthTokenHeader,
|
||||
setupAxiosInterceptors,
|
||||
};
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
GetTrajectoryResponse,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/types/settings";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
|
||||
class OpenHands {
|
||||
/**
|
||||
@@ -154,6 +154,25 @@ class OpenHands {
|
||||
return response.status === 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Github Token
|
||||
* @returns Refreshed Github access token
|
||||
*/
|
||||
static async refreshToken(
|
||||
appMode: GetConfigResponse["APP_MODE"],
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
if (appMode === "oss") return "";
|
||||
|
||||
const response = await openHands.post<GitHubAccessTokenResponse>(
|
||||
"/api/refresh-token",
|
||||
{
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return response.data.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob of the workspace zip
|
||||
* @returns Blob of the workspace zip
|
||||
@@ -223,11 +242,13 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
githubToken?: string,
|
||||
selectedRepository?: string,
|
||||
initialUserMsg?: string,
|
||||
imageUrls?: string[],
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
github_token: githubToken,
|
||||
selected_repository: selectedRepository,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
@@ -254,6 +275,35 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchEvents(
|
||||
conversationId: string,
|
||||
params: {
|
||||
query?: string;
|
||||
startId?: number;
|
||||
limit?: number;
|
||||
eventType?: string;
|
||||
source?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
},
|
||||
): Promise<{ events: Record<string, unknown>[]; has_more: boolean }> {
|
||||
const { data } = await openHands.get<{
|
||||
events: Record<string, unknown>[];
|
||||
has_more: boolean;
|
||||
}>(`/api/conversations/${conversationId}/events/search`, {
|
||||
params: {
|
||||
query: params.query,
|
||||
start_id: params.startId,
|
||||
limit: params.limit,
|
||||
event_type: params.eventType,
|
||||
source: params.source,
|
||||
start_date: params.startDate,
|
||||
end_date: params.endDate,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the settings from the server or use the default settings if not found
|
||||
*/
|
||||
@@ -318,10 +368,6 @@ class OpenHands {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async logout(): Promise<void> {
|
||||
await openHands.post("/api/logout");
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -2,9 +2,9 @@ import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { DownloadModal } from "#/components/shared/download-modal";
|
||||
import type { RootState } from "#/store";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
interface ActionSuggestionsProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -13,7 +13,7 @@ interface ActionSuggestionsProps {
|
||||
export function ActionSuggestions({
|
||||
onSuggestionsClick,
|
||||
}: ActionSuggestionsProps) {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
@@ -32,7 +32,7 @@ export function ActionSuggestions({
|
||||
onClose={handleDownloadClose}
|
||||
isOpen={isDownloading}
|
||||
/>
|
||||
{githubTokenIsSet && selectedRepository ? (
|
||||
{gitHubToken && selectedRepository ? (
|
||||
<div className="flex flex-row gap-2 justify-center w-full">
|
||||
{!hasPullRequest ? (
|
||||
<>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ChatMessage({
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
<Markdown
|
||||
className="text-sm overflow-auto break-words"
|
||||
className="text-sm overflow-auto"
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
|
||||
@@ -5,12 +5,14 @@ import { SuggestionBox } from "#/components/features/suggestions/suggestion-box"
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import { GitHubRepositorySelector } from "./github-repo-selector";
|
||||
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
|
||||
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
|
||||
interface GitHubRepositoriesSuggestionBoxProps {
|
||||
handleSubmit: () => void;
|
||||
@@ -49,7 +51,7 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
}
|
||||
};
|
||||
|
||||
const isLoggedIn = !!user;
|
||||
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -74,9 +76,11 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
}
|
||||
/>
|
||||
{connectToGitHubModalOpen && (
|
||||
<AccountSettingsModal
|
||||
onClose={() => setConnectToGitHubModalOpen(false)}
|
||||
/>
|
||||
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
|
||||
<ConnectToGitHubModal
|
||||
onClose={() => setConnectToGitHubModalOpen(false)}
|
||||
/>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { FaListUl } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { UserActions } from "./user-actions";
|
||||
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
|
||||
import { DocsButton } from "#/components/shared/buttons/docs-button";
|
||||
@@ -20,17 +21,20 @@ import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
export function Sidebar() {
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
const user = useGitHubUser();
|
||||
const { data: config } = useConfig();
|
||||
const { data: settings, isError: settingsError } = useSettings();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { logout } = useAuth();
|
||||
const {
|
||||
data: settings,
|
||||
isError: settingsIsError,
|
||||
isSuccess: settingsSuccessfulyFetched,
|
||||
} = useSettings();
|
||||
|
||||
const { isUpToDate: settingsAreUpToDate } = useCurrentSettings();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
@@ -52,14 +56,15 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
const handleAccountSettingsModalClose = () => {
|
||||
// If the user closes the modal without connecting to GitHub,
|
||||
// we need to log them out to clear the invalid token from the
|
||||
// local storage
|
||||
if (user.isError) logout();
|
||||
setAccountSettingsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else await saveUserSettings({ unset_github_token: true });
|
||||
posthog.reset();
|
||||
};
|
||||
const showSettingsModal =
|
||||
isAuthed && (!settingsAreUpToDate || settingsModalIsOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -87,7 +92,7 @@ export function Sidebar() {
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
}
|
||||
onLogout={handleLogout}
|
||||
onLogout={logout}
|
||||
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -105,12 +110,13 @@ export function Sidebar() {
|
||||
{accountSettingsModalOpen && (
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{(settingsError || settingsModalIsOpen) && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{settingsIsError ||
|
||||
(showSettingsModal && settingsSuccessfulyFetched && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,14 +10,11 @@ import { TOSCheckbox } from "./tos-checkbox";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
|
||||
interface WaitlistModalProps {
|
||||
ghTokenIsSet: boolean;
|
||||
ghToken: string | null;
|
||||
githubAuthUrl: string | null;
|
||||
}
|
||||
|
||||
export function WaitlistModal({
|
||||
ghTokenIsSet,
|
||||
githubAuthUrl,
|
||||
}: WaitlistModalProps) {
|
||||
export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
|
||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
@@ -31,11 +28,11 @@ export function WaitlistModal({
|
||||
<ModalBackdrop>
|
||||
<ModalBody>
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<WaitlistMessage content={ghTokenIsSet ? "waitlist" : "sign-in"} />
|
||||
<WaitlistMessage content={ghToken ? "waitlist" : "sign-in"} />
|
||||
|
||||
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
|
||||
|
||||
{!ghTokenIsSet && (
|
||||
{!ghToken && (
|
||||
<ModalButton
|
||||
disabled={!isTosAccepted}
|
||||
text="Connect to GitHub"
|
||||
@@ -44,7 +41,7 @@ export function WaitlistModal({
|
||||
onClick={handleGitHubAuth}
|
||||
/>
|
||||
)}
|
||||
{ghTokenIsSet && <JoinWaitlistAnchor />}
|
||||
{ghToken && <JoinWaitlistAnchor />}
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ConnectionStatusModal } from "../connection-status-modal";
|
||||
|
||||
vi.mock("../base-modal/base-modal", () => ({
|
||||
BaseModal: ({
|
||||
children,
|
||||
isOpen,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
}) => (isOpen ? <div>{children}</div> : null),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
key === "MODAL$UNSTABLE_CONNECTION"
|
||||
? "Connection is unstable, attempting to reconnect..."
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ConnectionStatusModal", () => {
|
||||
it("should show modal when connection is unstable", () => {
|
||||
render(<ConnectionStatusModal isOpen />);
|
||||
expect(
|
||||
screen.getByText("Connection is unstable, attempting to reconnect..."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show modal when connection is stable", () => {
|
||||
render(<ConnectionStatusModal isOpen={false} />);
|
||||
expect(
|
||||
screen.queryByText("Connection is unstable, attempting to reconnect..."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
@@ -8,13 +7,13 @@ import {
|
||||
import { ModalBody } from "../modal-body";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
import { CustomInput } from "../../custom-input";
|
||||
import { FormFieldset } from "../../form-fieldset";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { GitHubTokenInput } from "./github-token-input";
|
||||
import { PostSettings } from "#/types/settings";
|
||||
|
||||
interface AccountSettingsFormProps {
|
||||
onClose: () => void;
|
||||
@@ -29,12 +28,11 @@ export function AccountSettingsForm({
|
||||
gitHubError,
|
||||
analyticsConsent,
|
||||
}: AccountSettingsFormProps) {
|
||||
const { gitHubToken, setGitHubToken, logout } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { saveUserSettings, settings } = useCurrentSettings();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET;
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
@@ -43,9 +41,7 @@ export function AccountSettingsForm({
|
||||
const language = formData.get("language")?.toString();
|
||||
const analytics = formData.get("analytics")?.toString() === "on";
|
||||
|
||||
const newSettings: Partial<PostSettings> = {};
|
||||
|
||||
if (ghToken) newSettings.github_token = ghToken;
|
||||
if (ghToken) setGitHubToken(ghToken);
|
||||
|
||||
// The form returns the language label, so we need to find the corresponding
|
||||
// language key to save it in the settings
|
||||
@@ -54,11 +50,9 @@ export function AccountSettingsForm({
|
||||
({ label }) => label === language,
|
||||
)?.value;
|
||||
|
||||
if (languageKey) newSettings.LANGUAGE = languageKey;
|
||||
if (languageKey) await saveUserSettings({ LANGUAGE: languageKey });
|
||||
}
|
||||
|
||||
await saveUserSettings(newSettings);
|
||||
|
||||
handleCaptureConsent(analytics);
|
||||
const ANALYTICS = analytics.toString();
|
||||
localStorage.setItem("analytics-consent", ANALYTICS);
|
||||
@@ -66,12 +60,6 @@ export function AccountSettingsForm({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onDisconnect = async () => {
|
||||
await saveUserSettings({ unset_github_token: true });
|
||||
posthog.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBody testID="account-settings-form">
|
||||
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
|
||||
@@ -101,20 +89,23 @@ export function AccountSettingsForm({
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
<>
|
||||
<GitHubTokenInput githubTokenIsSet={githubTokenIsSet} />
|
||||
{!githubTokenIsSet && (
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.COMMON$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
)}
|
||||
<CustomInput
|
||||
name="ghToken"
|
||||
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.COMMON$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
</>
|
||||
)}
|
||||
{gitHubError && (
|
||||
@@ -122,12 +113,14 @@ export function AccountSettingsForm({
|
||||
{t(I18nKey.GITHUB$TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{githubTokenIsSet && !gitHubError && (
|
||||
{gitHubToken && !gitHubError && (
|
||||
<ModalButton
|
||||
testId="disconnect-github"
|
||||
variant="text-like"
|
||||
text={t(I18nKey.BUTTON$DISCONNECT)}
|
||||
onClick={onDisconnect}
|
||||
onClick={() => {
|
||||
logout();
|
||||
onClose();
|
||||
}}
|
||||
className="text-danger self-start"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCheckCircle } from "react-icons/fa";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface GitHubTokenInputProps {
|
||||
githubTokenIsSet: boolean;
|
||||
}
|
||||
|
||||
export function GitHubTokenInput({ githubTokenIsSet }: GitHubTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label htmlFor="ghToken" className="flex flex-col gap-2">
|
||||
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3] flex items-center gap-1">
|
||||
{githubTokenIsSet && (
|
||||
<FaCheckCircle
|
||||
data-testid="github-token-set-checkmark"
|
||||
size={12}
|
||||
className="text-[#00D1B2]"
|
||||
/>
|
||||
)}
|
||||
{t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
<span className="text-[#A3A3A3]">
|
||||
{" "}
|
||||
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
|
||||
</span>
|
||||
</span>
|
||||
{!githubTokenIsSet && (
|
||||
<input
|
||||
data-testid="github-token-input"
|
||||
id="ghToken"
|
||||
name="ghToken"
|
||||
type="password"
|
||||
className="bg-[#27272A] text-xs py-[10px] px-3 rounded"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBody } from "./modal-body";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
} from "./confirmation-modals/base-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { ModalButton } from "../buttons/modal-button";
|
||||
import { CustomInput } from "../custom-input";
|
||||
|
||||
interface ConnectToGitHubModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
const { gitHubToken, setGitHubToken } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const ghToken = formData.get("ghToken")?.toString();
|
||||
|
||||
if (ghToken) setGitHubToken(ghToken);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-2 self-start">
|
||||
<BaseModalTitle title="Connect to GitHub" />
|
||||
<BaseModalDescription
|
||||
description={
|
||||
<span>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="w-full flex flex-col gap-6">
|
||||
<CustomInput
|
||||
label="GitHub Token"
|
||||
name="ghToken"
|
||||
required
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
testId="connect-to-github"
|
||||
type="submit"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)}
|
||||
className="bg-[#791B80] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
onClick={onClose}
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CLOSE)}
|
||||
className="bg-[#737373] w-full"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseModal } from "./base-modal/base-modal";
|
||||
import { LoadingSpinner } from "../loading-spinner";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConnectionStatusModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionStatusModal({ isOpen }: ConnectionStatusModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {}}
|
||||
showCloseButton={false}
|
||||
className="max-w-md"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center p-6 space-y-4">
|
||||
<LoadingSpinner className="w-8 h-8" />
|
||||
<p className="text-lg font-medium text-gray-700">
|
||||
{t(I18nKey.MODAL$UNSTABLE_CONNECTION)}
|
||||
</p>
|
||||
</div>
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { getDefaultSettings } from "#/services/settings";
|
||||
import { getDefaultSettings, Settings } from "#/services/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
|
||||
import { DangerModal } from "../confirmation-modals/danger-modal";
|
||||
import { extractSettings } from "#/utils/settings-utils";
|
||||
import { extractSettings, saveSettingsView } from "#/utils/settings-utils";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
import { AdvancedOptionSwitch } from "../../inputs/advanced-option-switch";
|
||||
@@ -23,8 +23,6 @@ import { ModelSelector } from "./model-selector";
|
||||
import { RuntimeSizeSelector } from "./runtime-size-selector";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { MEMORY_CONDENSER } from "#/utils/feature-flags";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@@ -66,14 +64,12 @@ export function SettingsForm({
|
||||
const isUsingConfirmationMode = !!settings.CONFIRMATION_MODE;
|
||||
const isUsingBaseUrl = !!settings.LLM_BASE_URL;
|
||||
const isUsingCustomModel = !!settings.LLM_MODEL && !isKnownModel;
|
||||
const isUsingDefaultCondenser = !!settings.ENABLE_DEFAULT_CONDENSER;
|
||||
|
||||
return (
|
||||
isUsingSecurityAnalyzer ||
|
||||
isUsingConfirmationMode ||
|
||||
isUsingBaseUrl ||
|
||||
isUsingCustomModel ||
|
||||
isUsingDefaultCondenser
|
||||
isUsingCustomModel
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,11 +90,11 @@ export function SettingsForm({
|
||||
};
|
||||
|
||||
const handleFormSubmission = async (formData: FormData) => {
|
||||
const keys = Array.from(formData.keys());
|
||||
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
|
||||
const newSettings = extractSettings(formData);
|
||||
|
||||
// Inject the condenser config from the current feature flag value
|
||||
newSettings.ENABLE_DEFAULT_CONDENSER = MEMORY_CONDENSER;
|
||||
|
||||
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
|
||||
await saveUserSettings(newSettings);
|
||||
onClose();
|
||||
resetOngoingSession();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "../../loading-spinner";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { SettingsForm } from "./settings-form";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
interface SettingsModalProps {
|
||||
settings: Settings;
|
||||
|
||||
@@ -1,21 +1,110 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
|
||||
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
|
||||
} from "#/api/open-hands-axios";
|
||||
import {
|
||||
setAuthTokenHeader as setGitHubAuthTokenHeader,
|
||||
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
|
||||
setupAxiosInterceptors as setupGithubAxiosInterceptors,
|
||||
} from "#/api/github-axios-instance";
|
||||
|
||||
interface AuthContextType {
|
||||
githubTokenIsSet: boolean;
|
||||
setGitHubTokenIsSet: (value: boolean) => void;
|
||||
gitHubToken: string | null;
|
||||
setUserId: (userId: string) => void;
|
||||
setGitHubToken: (token: string | null) => void;
|
||||
clearGitHubToken: () => void;
|
||||
refreshToken: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(false);
|
||||
const [gitHubTokenState, setGitHubTokenState] = React.useState<string | null>(
|
||||
() => localStorage.getItem("ghToken"),
|
||||
);
|
||||
|
||||
const [userIdState, setUserIdState] = React.useState<string>(
|
||||
() => localStorage.getItem("userId") || "",
|
||||
);
|
||||
|
||||
const clearGitHubToken = () => {
|
||||
setGitHubTokenState(null);
|
||||
setUserIdState("");
|
||||
localStorage.removeItem("ghToken");
|
||||
localStorage.removeItem("userId");
|
||||
|
||||
removeOpenHandsGitHubTokenHeader();
|
||||
removeGitHubAuthTokenHeader();
|
||||
};
|
||||
|
||||
const setGitHubToken = (token: string | null) => {
|
||||
setGitHubTokenState(token);
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("ghToken", token);
|
||||
setOpenHandsGitHubTokenHeader(token);
|
||||
setGitHubAuthTokenHeader(token);
|
||||
} else {
|
||||
clearGitHubToken();
|
||||
}
|
||||
};
|
||||
|
||||
const setUserId = (userId: string) => {
|
||||
setUserIdState(userIdState);
|
||||
localStorage.setItem("userId", userId);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearGitHubToken();
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
const refreshToken = async (): Promise<boolean> => {
|
||||
const config = await OpenHands.getConfig();
|
||||
|
||||
if (config.APP_MODE !== "saas" || !gitHubTokenState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newToken = await OpenHands.refreshToken(config.APP_MODE, userIdState);
|
||||
if (newToken) {
|
||||
setGitHubToken(newToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearGitHubToken();
|
||||
return false;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const storedGitHubToken = localStorage.getItem("ghToken");
|
||||
|
||||
const userId = localStorage.getItem("userId") || "";
|
||||
|
||||
setGitHubToken(storedGitHubToken);
|
||||
setUserId(userId);
|
||||
const setupIntercepter = async () => {
|
||||
const config = await OpenHands.getConfig();
|
||||
setupGithubAxiosInterceptors(config.APP_MODE, refreshToken, logout);
|
||||
};
|
||||
|
||||
setupIntercepter();
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
githubTokenIsSet,
|
||||
setGitHubTokenIsSet,
|
||||
gitHubToken: gitHubTokenState,
|
||||
setGitHubToken,
|
||||
setUserId,
|
||||
clearGitHubToken,
|
||||
refreshToken,
|
||||
logout,
|
||||
}),
|
||||
[githubTokenIsSet, setGitHubTokenIsSet],
|
||||
[gitHubTokenState],
|
||||
);
|
||||
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import React from "react";
|
||||
import {
|
||||
LATEST_SETTINGS_VERSION,
|
||||
Settings,
|
||||
settingsAreUpToDate,
|
||||
} from "#/services/settings";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { PostSettings, Settings } from "#/types/settings";
|
||||
|
||||
interface SettingsContextType {
|
||||
saveUserSettings: (newSettings: Partial<PostSettings>) => Promise<void>;
|
||||
isUpToDate: boolean;
|
||||
setIsUpToDate: (value: boolean) => void;
|
||||
saveUserSettings: (newSettings: Partial<Settings>) => Promise<void>;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
@@ -20,8 +26,10 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
const { data: userSettings } = useSettings();
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
|
||||
const saveUserSettings = async (newSettings: Partial<PostSettings>) => {
|
||||
const updatedSettings: Partial<PostSettings> = {
|
||||
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
|
||||
|
||||
const saveUserSettings = async (newSettings: Partial<Settings>) => {
|
||||
const updatedSettings: Partial<Settings> = {
|
||||
...userSettings,
|
||||
...newSettings,
|
||||
};
|
||||
@@ -30,15 +38,27 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
delete updatedSettings.LLM_API_KEY;
|
||||
}
|
||||
|
||||
await saveSettings(updatedSettings);
|
||||
await saveSettings(updatedSettings, {
|
||||
onSuccess: () => {
|
||||
if (!isUpToDate) {
|
||||
localStorage.setItem(
|
||||
"SETTINGS_VERSION",
|
||||
LATEST_SETTINGS_VERSION.toString(),
|
||||
);
|
||||
setIsUpToDate(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
isUpToDate,
|
||||
setIsUpToDate,
|
||||
saveUserSettings,
|
||||
settings: userSettings,
|
||||
}),
|
||||
[saveUserSettings, userSettings],
|
||||
[isUpToDate, setIsUpToDate, saveUserSettings, userSettings],
|
||||
);
|
||||
|
||||
return <SettingsContext value={value}>{children}</SettingsContext>;
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
AssistantMessageAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { ConnectionStatusModal } from "#/components/shared/modals/connection-status-modal";
|
||||
|
||||
const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
typeof event === "object" &&
|
||||
@@ -219,16 +218,7 @@ export function WsClientProvider({
|
||||
[status, messageRateHandler.isUnderThreshold, events],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WsClientContext.Provider value={value}>
|
||||
{children}
|
||||
</WsClientContext.Provider>
|
||||
<ConnectionStatusModal
|
||||
isOpen={status === WsClientProviderStatus.DISCONNECTED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <WsClientContext value={value}>{children}</WsClientContext>;
|
||||
}
|
||||
|
||||
export function useWsClient() {
|
||||
|
||||
@@ -5,10 +5,12 @@ import { useDispatch, useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { setInitialPrompt } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { gitHubToken } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedRepository, files, importedProjectZip } = useSelector(
|
||||
@@ -29,6 +31,7 @@ export const useCreateConversation = () => {
|
||||
if (variables.q) dispatch(setInitialPrompt(variables.q));
|
||||
|
||||
return OpenHands.createConversation(
|
||||
gitHubToken || undefined,
|
||||
selectedRepository || undefined,
|
||||
variables.q,
|
||||
files,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useLogout = () => {
|
||||
const { setGitHubTokenIsSet } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: OpenHands.logout,
|
||||
onSuccess: async () => {
|
||||
setGitHubTokenIsSet(false);
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { ApiSettings, DEFAULT_SETTINGS, Settings } from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { PostSettings, PostApiSettings } from "#/types/settings";
|
||||
|
||||
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
const apiSettings: Partial<PostApiSettings> = {
|
||||
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
|
||||
const apiSettings: Partial<ApiSettings> = {
|
||||
llm_model: settings.LLM_MODEL,
|
||||
llm_base_url: settings.LLM_BASE_URL,
|
||||
agent: settings.AGENT || DEFAULT_SETTINGS.AGENT,
|
||||
@@ -12,10 +11,6 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
confirmation_mode: settings.CONFIRMATION_MODE,
|
||||
security_analyzer: settings.SECURITY_ANALYZER,
|
||||
llm_api_key: settings.LLM_API_KEY?.trim() || undefined,
|
||||
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
github_token: settings.github_token,
|
||||
unset_github_token: settings.unset_github_token,
|
||||
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
|
||||
};
|
||||
|
||||
await OpenHands.saveSettings(apiSettings);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useAppInstallations = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", githubTokenIsSet, config?.GITHUB_CLIENT_ID],
|
||||
queryKey: ["installations", gitHubToken, config?.GITHUB_CLIENT_ID],
|
||||
queryFn: OpenHands.getGitHubUserInstallationIds,
|
||||
enabled:
|
||||
githubTokenIsSet &&
|
||||
!!gitHubToken &&
|
||||
!!config?.GITHUB_CLIENT_ID &&
|
||||
config?.APP_MODE === "saas",
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { retrieveGitHubAppRepositories } from "#/api/github";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useAppInstallations } from "./use-app-installations";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useAppRepositories = () => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { data: installations } = useAppInstallations();
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", githubTokenIsSet, installations],
|
||||
queryKey: ["repositories", gitHubToken, installations],
|
||||
queryFn: async ({
|
||||
pageParam,
|
||||
}: {
|
||||
@@ -46,7 +46,7 @@ export const useAppRepositories = () => {
|
||||
return null;
|
||||
},
|
||||
enabled:
|
||||
githubTokenIsSet &&
|
||||
!!gitHubToken &&
|
||||
Array.isArray(installations) &&
|
||||
installations.length > 0 &&
|
||||
config?.APP_MODE === "saas",
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useLogout } from "../mutation/use-logout";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
|
||||
export const useGitHubUser = () => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { setGitHubTokenIsSet } = useAuth();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { gitHubToken, setUserId, logout } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const user = useQuery({
|
||||
queryKey: ["user", githubTokenIsSet],
|
||||
queryKey: ["user", gitHubToken],
|
||||
queryFn: OpenHands.getGitHubUser,
|
||||
enabled: githubTokenIsSet && !!config?.APP_MODE,
|
||||
enabled: !!gitHubToken && !!config?.APP_MODE,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user.data) {
|
||||
setUserId(user.data.id.toString());
|
||||
posthog.identify(user.data.login, {
|
||||
company: user.data.company,
|
||||
name: user.data.name,
|
||||
@@ -33,18 +29,9 @@ export const useGitHubUser = () => {
|
||||
}
|
||||
}, [user.data]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else {
|
||||
await saveUserSettings({ unset_github_token: true });
|
||||
setGitHubTokenIsSet(false);
|
||||
}
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user.isError) {
|
||||
handleLogout();
|
||||
logout();
|
||||
}
|
||||
}, [user.isError]);
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useIsAuthed = () => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "authenticated", githubTokenIsSet, appMode],
|
||||
queryKey: ["user", "authenticated", gitHubToken, appMode],
|
||||
queryFn: () => OpenHands.authenticate(appMode!),
|
||||
enabled: !!appMode,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useSearchEvents = (params: {
|
||||
query?: string;
|
||||
startId?: number;
|
||||
limit?: number;
|
||||
eventType?: string;
|
||||
source?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}) => {
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["search_events", conversationId, params],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return OpenHands.searchEvents(conversationId, params);
|
||||
},
|
||||
enabled: !!conversationId,
|
||||
});
|
||||
};
|
||||
@@ -1,36 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { AxiosError } from "axios";
|
||||
import { DEFAULT_SETTINGS, getLocalStorageSettings } from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
const getSettingsQueryFn = async () => {
|
||||
const apiSettings = await OpenHands.getSettings();
|
||||
try {
|
||||
const apiSettings = await OpenHands.getSettings();
|
||||
|
||||
return {
|
||||
LLM_MODEL: apiSettings.llm_model,
|
||||
LLM_BASE_URL: apiSettings.llm_base_url,
|
||||
AGENT: apiSettings.agent,
|
||||
LANGUAGE: apiSettings.language,
|
||||
CONFIRMATION_MODE: apiSettings.confirmation_mode,
|
||||
SECURITY_ANALYZER: apiSettings.security_analyzer,
|
||||
LLM_API_KEY: apiSettings.llm_api_key,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
|
||||
GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set,
|
||||
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
|
||||
};
|
||||
if (apiSettings !== null) {
|
||||
return {
|
||||
LLM_MODEL: apiSettings.llm_model,
|
||||
LLM_BASE_URL: apiSettings.llm_base_url,
|
||||
AGENT: apiSettings.agent,
|
||||
LANGUAGE: apiSettings.language,
|
||||
CONFIRMATION_MODE: apiSettings.confirmation_mode,
|
||||
SECURITY_ANALYZER: apiSettings.security_analyzer,
|
||||
LLM_API_KEY: apiSettings.llm_api_key,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
apiSettings.remote_runtime_resource_factor,
|
||||
};
|
||||
}
|
||||
|
||||
return getLocalStorageSettings();
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 404) {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const useSettings = () => {
|
||||
const { setGitHubTokenIsSet } = useAuth();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettingsQueryFn,
|
||||
initialData: DEFAULT_SETTINGS,
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -39,9 +47,5 @@ export const useSettings = () => {
|
||||
}
|
||||
}, [query.data?.LLM_API_KEY]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET);
|
||||
}, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { retrieveGitHubUserRepositories } from "#/api/github";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
|
||||
export const useUserRepositories = () => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", githubTokenIsSet],
|
||||
queryKey: ["repositories", gitHubToken],
|
||||
queryFn: async ({ pageParam }) =>
|
||||
retrieveGitHubUserRepositories(pageParam, 100),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
enabled: githubTokenIsSet && config?.APP_MODE === "oss",
|
||||
enabled: !!gitHubToken && config?.APP_MODE === "oss",
|
||||
});
|
||||
|
||||
// TODO: Once we create our custom dropdown component, we should fetch data onEndReached
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import React from "react";
|
||||
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
interface UseGitHubAuthUrlConfig {
|
||||
gitHubToken: string | null;
|
||||
appMode: GetConfigResponse["APP_MODE"] | null;
|
||||
gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null;
|
||||
}
|
||||
|
||||
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (config.appMode === "saas" && !githubTokenIsSet)
|
||||
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) =>
|
||||
React.useMemo(() => {
|
||||
if (config.appMode === "saas" && !config.gitHubToken)
|
||||
return generateGitHubAuthUrl(
|
||||
config.gitHubClientId || "",
|
||||
new URL(window.location.href),
|
||||
);
|
||||
|
||||
return null;
|
||||
}, [githubTokenIsSet, config.appMode, config.gitHubClientId]);
|
||||
};
|
||||
}, [config.gitHubToken, config.appMode, config.gitHubClientId]);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Sometimes we ship major changes, like a new default agent.
|
||||
|
||||
import React from "react";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import {
|
||||
getCurrentSettingsVersion,
|
||||
DEFAULT_SETTINGS,
|
||||
getLocalStorageSettings,
|
||||
} from "#/services/settings";
|
||||
import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
|
||||
// In this case, we may want to override a previous choice made by the user.
|
||||
export const useMaybeMigrateSettings = () => {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const { isUpToDate } = useCurrentSettings();
|
||||
|
||||
const maybeMigrateSettings = async () => {
|
||||
const currentVersion = getCurrentSettingsVersion();
|
||||
|
||||
if (currentVersion < 1) {
|
||||
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
|
||||
}
|
||||
if (currentVersion < 2) {
|
||||
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
|
||||
if (customModel) {
|
||||
localStorage.setItem("LLM_MODEL", customModel);
|
||||
}
|
||||
localStorage.removeItem("CUSTOM_LLM_MODEL");
|
||||
localStorage.removeItem("USING_CUSTOM_MODEL");
|
||||
}
|
||||
if (currentVersion < 3) {
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
if (currentVersion < 4) {
|
||||
// We used to log out here, but it's breaking things
|
||||
}
|
||||
|
||||
// Only save settings if user already previously saved settings
|
||||
// That way we avoid setting defaults for new users too early
|
||||
if (currentVersion !== 0 && currentVersion < 5) {
|
||||
const localSettings = getLocalStorageSettings();
|
||||
await saveSettings(localSettings);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isUpToDate) {
|
||||
maybeMigrateSettings();
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
@@ -1,19 +1,4 @@
|
||||
{
|
||||
"MODAL$UNSTABLE_CONNECTION": {
|
||||
"en": "Connection is unstable, attempting to reconnect...",
|
||||
"ja": "接続が不安定です。再接続を試みています...",
|
||||
"zh-CN": "连接不稳定,正在尝试重新连接...",
|
||||
"zh-TW": "連線不穩定,正在嘗試重新連線...",
|
||||
"ko-KR": "연결이 불안정합니다. 재연결을 시도하는 중...",
|
||||
"no": "Tilkoblingen er ustabil, prøver å koble til på nytt...",
|
||||
"it": "La connessione è instabile, tentativo di riconnessione in corso...",
|
||||
"pt": "A conexão está instável, tentando reconectar...",
|
||||
"es": "La conexión es inestable, intentando reconectar...",
|
||||
"ar": "الاتصال غير مستقر، جاري محاولة إعادة الاتصال...",
|
||||
"fr": "La connexion est instable, tentative de reconnexion...",
|
||||
"tr": "Bağlantı kararsız, yeniden bağlanmaya çalışılıyor...",
|
||||
"de": "Verbindung ist instabil, versuche neu zu verbinden..."
|
||||
},
|
||||
"APP$TITLE": {
|
||||
"en": "App",
|
||||
"ja": "アプリ",
|
||||
@@ -4521,8 +4506,5 @@
|
||||
"fr": "Que voulez-vous construire ?",
|
||||
"tr": "Ne inşa etmek istiyorsun?",
|
||||
"de": "Was möchten Sie erstellen?"
|
||||
},
|
||||
"SETTINGS_FORM$ENABLE_DEFAULT_CONDENSER_SWITCH_LABEL": {
|
||||
"en": "Enable Memory Condenser"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,17 @@ import {
|
||||
ResultSet,
|
||||
} from "#/api/open-hands.types";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { ApiSettings, PostApiSettings } from "#/types/settings";
|
||||
|
||||
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
|
||||
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
|
||||
agent: DEFAULT_SETTINGS.AGENT,
|
||||
language: DEFAULT_SETTINGS.LANGUAGE,
|
||||
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
|
||||
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
remote_runtime_resource_factor:
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
github_token_is_set: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET,
|
||||
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
|
||||
};
|
||||
|
||||
const MOCK_USER_PREFERENCES: {
|
||||
settings: ApiSettings | PostApiSettings;
|
||||
} = {
|
||||
settings: MOCK_DEFAULT_USER_SETTINGS,
|
||||
export const MOCK_USER_PREFERENCES = {
|
||||
settings: {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
|
||||
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
|
||||
agent: DEFAULT_SETTINGS.AGENT,
|
||||
language: DEFAULT_SETTINGS.LANGUAGE,
|
||||
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
|
||||
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
},
|
||||
};
|
||||
|
||||
const conversations: Conversation[] = [
|
||||
@@ -177,32 +168,17 @@ export const handlers = [
|
||||
|
||||
return HttpResponse.json(config);
|
||||
}),
|
||||
http.get("/api/settings", async () => {
|
||||
const settings: ApiSettings = {
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
};
|
||||
// @ts-expect-error - mock types
|
||||
if (settings.github_token) settings.github_token_is_set = true;
|
||||
|
||||
return HttpResponse.json(settings);
|
||||
}),
|
||||
http.get("/api/settings", async () =>
|
||||
HttpResponse.json(MOCK_USER_PREFERENCES.settings),
|
||||
),
|
||||
http.post("/api/settings", async ({ request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (body) {
|
||||
let newSettings: Partial<PostApiSettings> = {};
|
||||
if (typeof body === "object") {
|
||||
newSettings = { ...body };
|
||||
if (newSettings.unset_github_token) {
|
||||
newSettings.github_token = undefined;
|
||||
newSettings.github_token_is_set = false;
|
||||
delete newSettings.unset_github_token;
|
||||
}
|
||||
}
|
||||
|
||||
MOCK_USER_PREFERENCES.settings = {
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
...newSettings,
|
||||
// @ts-expect-error - We know this is a settings object
|
||||
...body,
|
||||
};
|
||||
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { QueryClientConfig, QueryCache } from "@tanstack/react-query";
|
||||
import { renderToastIfError } from "./utils/render-toast-if-error";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts", "settings"];
|
||||
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
|
||||
|
||||
export const queryClientConfig: QueryClientConfig = {
|
||||
queryCache: new QueryCache({
|
||||
onError: (error, query) => {
|
||||
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
|
||||
renderToastIfError(error);
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -18,7 +18,7 @@ export const queryClientConfig: QueryClientConfig = {
|
||||
},
|
||||
mutations: {
|
||||
onError: (error) => {
|
||||
renderToastIfError(error);
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { ImportProjectSuggestionBox } from "../../components/features/suggestions/import-project-suggestion-box";
|
||||
import { GitHubRepositoriesSuggestionBox } from "#/components/features/github/github-repositories-suggestion-box";
|
||||
import { HeroHeading } from "#/components/shared/hero-heading";
|
||||
@@ -15,6 +16,7 @@ import { TaskForm } from "#/components/shared/task-form";
|
||||
|
||||
function Home() {
|
||||
const { t } = useTranslation();
|
||||
const { gitHubToken } = useAuth();
|
||||
const dispatch = useDispatch();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
@@ -22,6 +24,7 @@ function Home() {
|
||||
const { data: user } = useGitHubUser();
|
||||
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
gitHubToken,
|
||||
appMode: config?.APP_MODE || null,
|
||||
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { FilesProvider } from "#/context/files";
|
||||
import { ChatInterface } from "../../components/features/chat/chat-interface";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { EventHandler } from "./event-handler";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
||||
import { Container } from "#/components/layout/container";
|
||||
import {
|
||||
@@ -41,6 +42,7 @@ import { RootState } from "#/store";
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
const { t } = useTranslation();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: settings } = useSettings();
|
||||
const { conversationId } = useConversation();
|
||||
const { data: conversation, isFetched } = useUserConversation(
|
||||
@@ -55,9 +57,8 @@ function AppContent() {
|
||||
const [width, setWidth] = React.useState(window.innerWidth);
|
||||
|
||||
const secrets = React.useMemo(
|
||||
// secrets to filter go here
|
||||
() => [].filter((secret) => secret !== null),
|
||||
[],
|
||||
() => [gitHubToken].filter((secret) => secret !== null),
|
||||
[gitHubToken],
|
||||
);
|
||||
|
||||
const Terminal = React.useMemo(
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useRouteError, isRouteErrorResponse, Outlet } from "react-router";
|
||||
import i18n from "#/i18n";
|
||||
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
|
||||
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useMaybeMigrateSettings } from "#/hooks/use-maybe-migrate-settings";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -43,7 +44,9 @@ export function ErrorBoundary() {
|
||||
}
|
||||
|
||||
export default function MainApp() {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
useMaybeMigrateSettings();
|
||||
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
|
||||
@@ -51,13 +54,10 @@ export default function MainApp() {
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const {
|
||||
data: isAuthed,
|
||||
isFetching: isFetchingAuth,
|
||||
isError: authError,
|
||||
} = useIsAuthed();
|
||||
const { data: isAuthed, isFetching: isFetchingAuth } = useIsAuthed();
|
||||
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
gitHubToken,
|
||||
appMode: config.data?.APP_MODE || null,
|
||||
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
|
||||
});
|
||||
@@ -68,9 +68,8 @@ export default function MainApp() {
|
||||
}
|
||||
}, [settings?.LANGUAGE]);
|
||||
|
||||
const userIsAuthed = !!isAuthed && !authError;
|
||||
const renderWaitlistModal =
|
||||
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
|
||||
const isInWaitlist =
|
||||
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -86,11 +85,8 @@ export default function MainApp() {
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{renderWaitlistModal && (
|
||||
<WaitlistModal
|
||||
ghTokenIsSet={githubTokenIsSet}
|
||||
githubAuthUrl={gitHubAuthUrl}
|
||||
/>
|
||||
{isInWaitlist && (
|
||||
<WaitlistModal ghToken={gitHubToken} githubAuthUrl={gitHubAuthUrl} />
|
||||
)}
|
||||
|
||||
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (
|
||||
|
||||
@@ -2,13 +2,16 @@ import { useNavigate, useSearchParams } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
function OAuthGitHubCallback() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setGitHubToken } = useAuth();
|
||||
|
||||
const code = searchParams.get("code");
|
||||
|
||||
const { isSuccess, error } = useQuery({
|
||||
const { data, isSuccess, error } = useQuery({
|
||||
queryKey: ["access_token", code],
|
||||
queryFn: () => OpenHands.getGitHubAccessToken(code!),
|
||||
enabled: !!code,
|
||||
@@ -16,6 +19,7 @@ function OAuthGitHubCallback() {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSuccess) {
|
||||
setGitHubToken(data.access_token);
|
||||
navigate("/");
|
||||
}
|
||||
}, [isSuccess]);
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
export const LATEST_SETTINGS_VERSION = 5;
|
||||
|
||||
export type Settings = {
|
||||
LLM_MODEL: string;
|
||||
LLM_BASE_URL: string;
|
||||
AGENT: string;
|
||||
LANGUAGE: string;
|
||||
LLM_API_KEY: string | null;
|
||||
CONFIRMATION_MODE: boolean;
|
||||
SECURITY_ANALYZER: string;
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
llm_model: string;
|
||||
llm_base_url: string;
|
||||
agent: string;
|
||||
language: string;
|
||||
llm_api_key: string | null;
|
||||
confirmation_mode: boolean;
|
||||
security_analyzer: string;
|
||||
remote_runtime_resource_factor: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
LLM_MODEL: "anthropic/claude-3-5-sonnet-20241022",
|
||||
LLM_BASE_URL: "",
|
||||
@@ -11,11 +31,55 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
CONFIRMATION_MODE: false,
|
||||
SECURITY_ANALYZER: "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
|
||||
GITHUB_TOKEN_IS_SET: false,
|
||||
ENABLE_DEFAULT_CONDENSER: false,
|
||||
};
|
||||
|
||||
export const getCurrentSettingsVersion = () => {
|
||||
const settingsVersion = localStorage.getItem("SETTINGS_VERSION");
|
||||
if (!settingsVersion) return 0;
|
||||
try {
|
||||
return parseInt(settingsVersion, 10);
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const settingsAreUpToDate = () =>
|
||||
getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
|
||||
|
||||
// TODO: localStorage settings are deprecated. Remove this after 1/31/2025
|
||||
/**
|
||||
* Get the settings from local storage
|
||||
* @returns the settings from local storage
|
||||
* @deprecated
|
||||
*/
|
||||
export const getLocalStorageSettings = (): Settings => {
|
||||
const llmModel = localStorage.getItem("LLM_MODEL");
|
||||
const baseUrl = localStorage.getItem("LLM_BASE_URL");
|
||||
const agent = localStorage.getItem("AGENT");
|
||||
const language = localStorage.getItem("LANGUAGE");
|
||||
const llmApiKey = localStorage.getItem("LLM_API_KEY");
|
||||
const confirmationMode = localStorage.getItem("CONFIRMATION_MODE") === "true";
|
||||
const securityAnalyzer = localStorage.getItem("SECURITY_ANALYZER");
|
||||
|
||||
return {
|
||||
LLM_MODEL: llmModel || DEFAULT_SETTINGS.LLM_MODEL,
|
||||
LLM_BASE_URL: baseUrl || DEFAULT_SETTINGS.LLM_BASE_URL,
|
||||
AGENT: agent || DEFAULT_SETTINGS.AGENT,
|
||||
LANGUAGE: language || DEFAULT_SETTINGS.LANGUAGE,
|
||||
LLM_API_KEY: llmApiKey || DEFAULT_SETTINGS.LLM_API_KEY,
|
||||
CONFIRMATION_MODE: confirmationMode || DEFAULT_SETTINGS.CONFIRMATION_MODE,
|
||||
SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default settings
|
||||
*/
|
||||
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
|
||||
|
||||
/**
|
||||
* Get the current settings, either from local storage or defaults
|
||||
*/
|
||||
export const getSettings = (): Settings => getLocalStorageSettings();
|
||||
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
import "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
declare module "@tanstack/react-query" {
|
||||
interface Register {
|
||||
defaultError: AxiosError;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
export type Settings = {
|
||||
LLM_MODEL: string;
|
||||
LLM_BASE_URL: string;
|
||||
AGENT: string;
|
||||
LANGUAGE: string;
|
||||
LLM_API_KEY: string | null;
|
||||
CONFIRMATION_MODE: boolean;
|
||||
SECURITY_ANALYZER: string;
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
|
||||
GITHUB_TOKEN_IS_SET: boolean;
|
||||
ENABLE_DEFAULT_CONDENSER: boolean;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
llm_model: string;
|
||||
llm_base_url: string;
|
||||
agent: string;
|
||||
language: string;
|
||||
llm_api_key: string | null;
|
||||
confirmation_mode: boolean;
|
||||
security_analyzer: string;
|
||||
remote_runtime_resource_factor: number;
|
||||
github_token_is_set: boolean;
|
||||
enable_default_condenser: boolean;
|
||||
};
|
||||
|
||||
export type PostSettings = Settings & {
|
||||
github_token: string;
|
||||
unset_github_token: boolean;
|
||||
};
|
||||
|
||||
export type PostApiSettings = ApiSettings & {
|
||||
github_token: string;
|
||||
unset_github_token: boolean;
|
||||
};
|
||||
@@ -13,4 +13,3 @@ function loadFeatureFlag(
|
||||
}
|
||||
|
||||
export const MULTI_CONVERSATION_UI = loadFeatureFlag("MULTI_CONVERSATION_UI");
|
||||
export const MEMORY_CONDENSER = loadFeatureFlag("MEMORY_CONDENSER");
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { AxiosError } from "axios";
|
||||
import toast from "react-hot-toast";
|
||||
import { isAxiosErrorWithResponse } from "./type-guards";
|
||||
|
||||
/**
|
||||
* Renders a toast with the error message from an Axios error
|
||||
* @param error The error to render a toast for
|
||||
*/
|
||||
export const renderToastIfError = (error: AxiosError) => {
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
if (isAxiosErrorWithResponse(error) && error.response?.data.error) {
|
||||
errorMessage = error.response?.data.error;
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
toast.error(errorMessage || "An error occurred");
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Settings } from "#/types/settings";
|
||||
import { Settings } from "#/services/settings";
|
||||
|
||||
const extractBasicFormData = (formData: FormData) => {
|
||||
const provider = formData.get("llm-provider")?.toString();
|
||||
@@ -44,7 +44,7 @@ const extractAdvancedFormData = (formData: FormData) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const extractSettings = (formData: FormData): Partial<Settings> => {
|
||||
const extractSettings = (formData: FormData): Partial<Settings> => {
|
||||
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
|
||||
extractBasicFormData(formData);
|
||||
|
||||
@@ -65,3 +65,12 @@ export const extractSettings = (formData: FormData): Partial<Settings> => {
|
||||
SECURITY_ANALYZER,
|
||||
};
|
||||
};
|
||||
|
||||
const saveSettingsView = (view: "basic" | "advanced") => {
|
||||
localStorage.setItem(
|
||||
"use-advanced-options",
|
||||
view === "advanced" ? "true" : "false",
|
||||
);
|
||||
};
|
||||
|
||||
export { extractSettings, saveSettingsView };
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const getCachedConfig = (): { [key: string]: string } => {
|
||||
const config = localStorage.getItem("ALL_SETTINGS");
|
||||
if (config === null || config === undefined) return {};
|
||||
try {
|
||||
return JSON.parse(config);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export { getCachedConfig };
|
||||
@@ -1,9 +0,0 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
export const isAxiosErrorWithResponse = (
|
||||
error: AxiosError,
|
||||
): error is AxiosError<{ error: string }> =>
|
||||
typeof error.response?.data === "object" &&
|
||||
error.response?.data !== null &&
|
||||
"error" in error.response.data &&
|
||||
typeof error.response?.data?.error === "string";
|
||||
@@ -34,6 +34,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("FEATURE_MULTI_CONVERSATION_UI", "true");
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "5");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "5");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "4");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ type: repo
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
|
||||
This repository contains the code for runtime-API, an automated AI software engineer. It has a Python backend
|
||||
(in the `openhands` directory) and React frontend (in the `frontend` directory).
|
||||
|
||||
## General Setup:
|
||||
|
||||
@@ -433,6 +433,26 @@ class CodeActAgent(Agent):
|
||||
],
|
||||
)
|
||||
]
|
||||
example_message = self.prompt_manager.get_example_user_message()
|
||||
if example_message:
|
||||
messages.append(
|
||||
Message(
|
||||
role='user',
|
||||
content=[TextContent(text=example_message)],
|
||||
cache_prompt=self.llm.is_caching_prompt_active(),
|
||||
)
|
||||
)
|
||||
|
||||
# Repository and runtime info
|
||||
additional_info = self.prompt_manager.get_additional_info()
|
||||
if self.config.enable_prompt_extensions and additional_info:
|
||||
# only add these if prompt extension is enabled
|
||||
messages.append(
|
||||
Message(
|
||||
role='user',
|
||||
content=[TextContent(text=additional_info)],
|
||||
)
|
||||
)
|
||||
|
||||
pending_tool_call_action_messages: dict[str, Message] = {}
|
||||
tool_call_id_to_message: dict[str, Message] = {}
|
||||
@@ -440,7 +460,6 @@ class CodeActAgent(Agent):
|
||||
# Condense the events from the state.
|
||||
events = self.condenser.condensed_history(state)
|
||||
|
||||
is_first_message_handled = False
|
||||
for event in events:
|
||||
# create a regular message from an event
|
||||
if isinstance(event, Action):
|
||||
@@ -482,22 +501,11 @@ class CodeActAgent(Agent):
|
||||
for response_id in _response_ids_to_remove:
|
||||
pending_tool_call_action_messages.pop(response_id)
|
||||
|
||||
for msg in messages_to_add:
|
||||
if msg:
|
||||
if msg.role == 'user' and not is_first_message_handled:
|
||||
is_first_message_handled = True
|
||||
# compose the first user message with examples
|
||||
self.prompt_manager.add_examples_to_initial_message(msg)
|
||||
|
||||
# and/or repo/runtime info
|
||||
if self.config.enable_prompt_extensions:
|
||||
self.prompt_manager.add_info_to_initial_message(msg)
|
||||
|
||||
# enhance the user message with additional context based on keywords matched
|
||||
if msg.role == 'user':
|
||||
self.prompt_manager.enhance_message(msg)
|
||||
|
||||
messages.append(msg)
|
||||
for message in messages_to_add:
|
||||
if message:
|
||||
if message.role == 'user':
|
||||
self.prompt_manager.enhance_message(message)
|
||||
messages.append(message)
|
||||
|
||||
if self.llm.is_caching_prompt_active():
|
||||
# NOTE: this is only needed for anthropic
|
||||
@@ -505,7 +513,7 @@ class CodeActAgent(Agent):
|
||||
# https://github.com/anthropics/anthropic-quickstarts/blob/8f734fd08c425c6ec91ddd613af04ff87d70c5a0/computer-use-demo/computer_use_demo/loop.py#L241-L262
|
||||
breakpoints_remaining = 3 # remaining 1 for system/tool
|
||||
for message in reversed(messages):
|
||||
if message.role in ('user', 'tool'):
|
||||
if message.role == 'user' or message.role == 'tool':
|
||||
if breakpoints_remaining > 0:
|
||||
message.content[
|
||||
-1
|
||||
|
||||
@@ -12,10 +12,7 @@ from litellm import (
|
||||
ModelResponse,
|
||||
)
|
||||
|
||||
from openhands.core.exceptions import (
|
||||
FunctionCallNotExistsError,
|
||||
FunctionCallValidationError,
|
||||
)
|
||||
from openhands.core.exceptions import FunctionCallNotExistsError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
@@ -497,19 +494,15 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
f'Failed to parse tool call arguments: {tool_call.function.arguments}'
|
||||
) from e
|
||||
if tool_call.function.name == 'execute_bash':
|
||||
# this is an LLM error: add empty command to avoid breaking the tool call
|
||||
if 'command' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "command" in tool call {tool_call.function.name}'
|
||||
)
|
||||
arguments['command'] = ''
|
||||
# convert is_input to boolean
|
||||
is_input = arguments.get('is_input', 'false') == 'true'
|
||||
action = CmdRunAction(command=arguments['command'], is_input=is_input)
|
||||
if 'is_input' in arguments:
|
||||
arguments['is_input'] = arguments['is_input'] == 'true'
|
||||
action = CmdRunAction(**arguments)
|
||||
elif tool_call.function.name == 'execute_ipython_cell':
|
||||
if 'code' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "code" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = IPythonRunCellAction(code=arguments['code'])
|
||||
action = IPythonRunCellAction(**arguments)
|
||||
elif tool_call.function.name == 'delegate_to_browsing_agent':
|
||||
action = AgentDelegateAction(
|
||||
agent='BrowsingAgent',
|
||||
@@ -518,30 +511,8 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
elif tool_call.function.name == 'finish':
|
||||
action = AgentFinishAction()
|
||||
elif tool_call.function.name == 'edit_file':
|
||||
if 'path' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "path" in tool call {tool_call.function.name}'
|
||||
)
|
||||
if 'content' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "content" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = FileEditAction(
|
||||
path=arguments['path'],
|
||||
content=arguments['content'],
|
||||
start=arguments.get('start', 1),
|
||||
end=arguments.get('end', -1),
|
||||
)
|
||||
action = FileEditAction(**arguments)
|
||||
elif tool_call.function.name == 'str_replace_editor':
|
||||
if 'command' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "command" in tool call {tool_call.function.name}'
|
||||
)
|
||||
if 'path' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "path" in tool call {tool_call.function.name}'
|
||||
)
|
||||
|
||||
# We implement this in agent_skills, which can be used via Jupyter
|
||||
# convert tool_call.function.arguments to kwargs that can be passed to file_editor
|
||||
code = f'print(file_editor(**{arguments}))'
|
||||
@@ -563,16 +534,8 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
impl_source=FileEditSource.OH_ACI,
|
||||
)
|
||||
elif tool_call.function.name == 'browser':
|
||||
if 'code' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "code" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = BrowseInteractiveAction(browser_actions=arguments['code'])
|
||||
elif tool_call.function.name == 'web_read':
|
||||
if 'url' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "url" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = BrowseURLAction(url=arguments['url'])
|
||||
else:
|
||||
raise FunctionCallNotExistsError(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
|
||||
<IMPORTANT>
|
||||
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
|
||||
* You should start exploring the file system with your view command, unless you need to explore more deeply.
|
||||
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
|
||||
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
|
||||
* You MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
|
||||
</IMPORTANT>
|
||||
|
||||
@@ -37,7 +37,7 @@ class SandboxConfig(BaseModel):
|
||||
This should be a JSON string that will be parsed into a dictionary.
|
||||
"""
|
||||
|
||||
remote_runtime_api_url: str | None = Field(default='http://localhost:8000')
|
||||
remote_runtime_api_url: str = Field(default='http://localhost:8000')
|
||||
local_runtime_url: str = Field(default='http://localhost')
|
||||
keep_runtime_alive: bool = Field(default=False)
|
||||
rm_all_containers: bool = Field(default=False)
|
||||
|
||||
+19
-37
@@ -57,7 +57,6 @@ class AsyncEventStreamWrapper:
|
||||
class EventStream:
|
||||
sid: str
|
||||
file_store: FileStore
|
||||
secrets: dict[str, str]
|
||||
# For each subscriber ID, there is a map of callback functions - useful
|
||||
# when there are multiple listeners
|
||||
_subscribers: dict[str, dict[str, Callable]]
|
||||
@@ -83,7 +82,6 @@ class EventStream:
|
||||
self._subscribers = {}
|
||||
self._lock = threading.Lock()
|
||||
self._cur_id = 0
|
||||
self.secrets = {}
|
||||
|
||||
# load the stream
|
||||
self.__post_init__()
|
||||
@@ -269,24 +267,10 @@ class EventStream:
|
||||
event._timestamp = datetime.now().isoformat()
|
||||
event._source = source # type: ignore [attr-defined]
|
||||
data = event_to_dict(event)
|
||||
data = self._replace_secrets(data)
|
||||
event = event_from_dict(data)
|
||||
if event.id is not None:
|
||||
self.file_store.write(self._get_filename_for_id(event.id), json.dumps(data))
|
||||
self._queue.put(event)
|
||||
|
||||
def set_secrets(self, secrets: dict[str, str]):
|
||||
self.secrets = secrets.copy()
|
||||
|
||||
def _replace_secrets(self, data: dict) -> dict:
|
||||
for key in data:
|
||||
if isinstance(data[key], dict):
|
||||
data[key] = self._replace_secrets(data[key])
|
||||
elif isinstance(data[key], str):
|
||||
for secret in self.secrets.values():
|
||||
data[key] = data[key].replace(secret, '<secret_hidden>')
|
||||
return data
|
||||
|
||||
def _run_queue_loop(self):
|
||||
self._queue_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._queue_loop)
|
||||
@@ -335,7 +319,7 @@ class EventStream:
|
||||
self,
|
||||
event,
|
||||
query: str | None = None,
|
||||
event_types: tuple[type[Event], ...] | None = None,
|
||||
event_type: str | None = None,
|
||||
source: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
@@ -344,16 +328,16 @@ class EventStream:
|
||||
|
||||
Args:
|
||||
event: The event to check
|
||||
query: Text to search for in event content
|
||||
event_type: Filter by event type classes (e.g., (FileReadAction, ) ).
|
||||
source: Filter by event source
|
||||
start_date: Filter events after this date (ISO format)
|
||||
end_date: Filter events before this date (ISO format)
|
||||
query (str, optional): Text to search for in event content
|
||||
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
|
||||
source (str, optional): Filter by event source
|
||||
start_date (str, optional): Filter events after this date (ISO format)
|
||||
end_date (str, optional): Filter events before this date (ISO format)
|
||||
|
||||
Returns:
|
||||
bool: True if the event should be filtered out, False if it matches all criteria
|
||||
"""
|
||||
if event_types and not isinstance(event, event_types):
|
||||
if event_type and not event.__class__.__name__ == event_type:
|
||||
return True
|
||||
|
||||
if source and not event.source.value == source:
|
||||
@@ -377,25 +361,23 @@ class EventStream:
|
||||
def get_matching_events(
|
||||
self,
|
||||
query: str | None = None,
|
||||
event_types: tuple[type[Event], ...] | None = None,
|
||||
event_type: str | None = None,
|
||||
source: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
start_id: int = 0,
|
||||
limit: int = 100,
|
||||
reverse: bool = False,
|
||||
) -> list[type[Event]]:
|
||||
) -> list:
|
||||
"""Get matching events from the event stream based on filters.
|
||||
|
||||
Args:
|
||||
query: Text to search for in event content
|
||||
event_types: Filter by event type classes (e.g., (FileReadAction, ) ).
|
||||
source: Filter by event source
|
||||
start_date: Filter events after this date (ISO format)
|
||||
end_date: Filter events before this date (ISO format)
|
||||
start_id: Starting ID in the event stream. Defaults to 0
|
||||
limit: Maximum number of events to return. Must be between 1 and 100. Defaults to 100
|
||||
reverse: Whether to retrieve events in reverse order. Defaults to False.
|
||||
query (str, optional): Text to search for in event content
|
||||
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
|
||||
source (str, optional): Filter by event source
|
||||
start_date (str, optional): Filter events after this date (ISO format)
|
||||
end_date (str, optional): Filter events before this date (ISO format)
|
||||
start_id (int): Starting ID in the event stream. Defaults to 0
|
||||
limit (int): Maximum number of events to return. Must be between 1 and 100. Defaults to 100
|
||||
|
||||
Returns:
|
||||
list: List of matching events (as dicts)
|
||||
@@ -408,13 +390,13 @@ class EventStream:
|
||||
|
||||
matching_events: list = []
|
||||
|
||||
for event in self.get_events(start_id=start_id, reverse=reverse):
|
||||
for event in self.get_events(start_id=start_id):
|
||||
if self._should_filter_event(
|
||||
event, query, event_types, source, start_date, end_date
|
||||
event, query, event_type, source, start_date, end_date
|
||||
):
|
||||
continue
|
||||
|
||||
matching_events.append(event)
|
||||
matching_events.append(event_to_dict(event))
|
||||
|
||||
# Stop if we have enough events
|
||||
if len(matching_events) >= limit:
|
||||
|
||||
@@ -200,6 +200,7 @@ ASSISTANT:
|
||||
Running the updated file:
|
||||
<function=execute_bash>
|
||||
<parameter=command>
|
||||
<parameter=command>
|
||||
python3 app.py > server.log 2>&1 &
|
||||
</parameter>
|
||||
</function>
|
||||
|
||||
@@ -185,4 +185,4 @@ You can customize how the AI agent approaches issue resolution by adding a `.ope
|
||||
## Troubleshooting
|
||||
|
||||
If you have any issues, please open an issue on this github repo, we're happy to help!
|
||||
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the OpenHands Slack workspace (see [the README](/README.md) for an invite link).
|
||||
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the [OpenHands Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) and ask there.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import atexit
|
||||
from functools import lru_cache
|
||||
from typing import Callable
|
||||
from uuid import UUID
|
||||
|
||||
import docker
|
||||
import requests
|
||||
@@ -26,7 +26,6 @@ from openhands.runtime.utils.command import get_action_execution_server_startup_
|
||||
from openhands.runtime.utils.log_streamer import LogStreamer
|
||||
from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.shutdown_listener import add_shutdown_listener
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
@@ -37,6 +36,13 @@ APP_PORT_RANGE_1 = (50000, 54999)
|
||||
APP_PORT_RANGE_2 = (55000, 59999)
|
||||
|
||||
|
||||
def stop_all_runtime_containers():
|
||||
stop_all_containers(CONTAINER_NAME_PREFIX)
|
||||
|
||||
|
||||
_atexit_registered = False
|
||||
|
||||
|
||||
class DockerRuntime(ActionExecutionClient):
|
||||
"""This runtime will subscribe the event stream.
|
||||
When receive an event, it will send the event to runtime-client which run inside the docker environment.
|
||||
@@ -49,8 +55,6 @@ class DockerRuntime(ActionExecutionClient):
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
|
||||
_shutdown_listener_id: UUID | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
@@ -62,10 +66,10 @@ class DockerRuntime(ActionExecutionClient):
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
):
|
||||
if not DockerRuntime._shutdown_listener_id:
|
||||
DockerRuntime._shutdown_listener_id = add_shutdown_listener(
|
||||
lambda: stop_all_containers(CONTAINER_NAME_PREFIX)
|
||||
)
|
||||
global _atexit_registered
|
||||
if not _atexit_registered:
|
||||
_atexit_registered = True
|
||||
atexit.register(stop_all_runtime_containers)
|
||||
|
||||
self.config = config
|
||||
self._runtime_initialized: bool = False
|
||||
|
||||
@@ -68,10 +68,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
'debug',
|
||||
'Setting workspace_base is not supported in the remote runtime.',
|
||||
)
|
||||
if self.config.sandbox.remote_runtime_api_url is None:
|
||||
raise ValueError(
|
||||
'remote_runtime_api_url is required in the remote runtime.'
|
||||
)
|
||||
|
||||
self.runtime_builder = RemoteRuntimeBuilder(
|
||||
self.config.sandbox.remote_runtime_api_url,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user