Compare commits

..

1 Commits

Author SHA1 Message Date
Graham Neubig
ddd6bb3830 Update run_infer to add github prior knowledge 2025-01-21 21:25:08 -05:00
180 changed files with 3244 additions and 7359 deletions

View File

@@ -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

View File

@@ -160,6 +160,7 @@ jobs:
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for DelegatorAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
@@ -173,42 +174,12 @@ jobs:
cat $REPORT_FILE_DELEGATOR_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
# -------------------------------------------------------------
# Run VisualBrowsingAgent tests for DeepSeek, limited to t05 and t06
- name: Wait a little bit (again)
run: sleep 5
- name: Configure config.toml for testing VisualBrowsingAgent (DeepSeek)
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 15
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for VisualBrowsingAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
# Find and export the visual browsing agent test results
REPORT_FILE_VISUALBROWSING_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/VisualBrowsingAgent/deepseek*_maxiter_15_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE_VISUALBROWSING_DEEPSEEK: $REPORT_FILE_VISUALBROWSING_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_VISUALBROWSING_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create archive of evaluation outputs
run: |
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/DelegatorAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/DelegatorAgent/* # Only include the actual result directories
- name: Upload evaluation results as artifact
uses: actions/upload-artifact@v4
@@ -256,7 +227,4 @@ jobs:
**Integration Tests Report Delegator (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_DELEGATOR_DEEPSEEK }}
---
**Integration Tests Report VisualBrowsing (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK }}
---
Download testing outputs (includes both Haiku and DeepSeek results): [Download](${{ steps.upload_results_artifact.outputs.artifact-url }})

View File

@@ -84,10 +84,6 @@ jobs:
run: |
python -m pip index versions openhands-ai > openhands_versions.txt
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
# Ensure requirements.txt ends with newline before appending
if [ -f requirements.txt ] && [ -s requirements.txt ]; then
sed -i -e '$a\' requirements.txt
fi
echo "openhands-ai==${OPENHANDS_VERSION}" >> requirements.txt
cat requirements.txt

View File

@@ -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.21-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.20-nikolaik`
## Develop inside Docker container

View File

@@ -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.21-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21
docker.all-hands.dev/all-hands-ai/openhands:0.20
```
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.

View File

@@ -75,7 +75,7 @@ workspace_base = "./workspace"
#run_as_openhands = true
# Runtime environment
#runtime = "docker"
#runtime = "eventstream"
# Name of the default agent
#default_agent = "CodeActAgent"
@@ -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"

View File

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

View File

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

View File

@@ -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!

View File

@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21 \
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
python -m openhands.core.cli
```

View File

@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21 \
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21
docker.all-hands.dev/all-hands-ai/openhands:0.20
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -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"

View File

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

View File

@@ -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) 提问!

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21 \
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
python -m openhands.core.cli
```

View File

@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21 \
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21
docker.all-hands.dev/all-hands-ai/openhands:0.20
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。

View File

@@ -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"

View File

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

View File

@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21 \
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21 \
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -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.21-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-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.21
docker.all-hands.dev/all-hands-ai/openhands:0.20
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -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.21-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

2077
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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": [

View File

@@ -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">

View File

@@ -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/>

View File

@@ -67,19 +67,20 @@ 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'
'The requirements specified in <pr_description> are an issue from GitHub on a popular open-source project. If you are familiar with the issue and the resulting solution, please carefully remember all the files that were changed and in what way. Come up with a detailed plan to reproduce the patch.\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'
'Your task is to make the minimal changes to non-tests files in the /workspace directory to ensure the <pr_description> is satisfied, ideally with something similar to the existing patch from GitHub, but if you are not familiar with it just code it out.\n'
'Follow these steps to resolve the issue:\n'
'1. As a first step, it might be a good idea to explore the repo to familiarize yourself with its structure.\n'
'2. Create a script to reproduce the error and execute it with `python <filename.py>` using the BashTool, to confirm the error\n'
'3. Edit the sourcecode of the repo to resolve the issue\n'
'4. Rerun your reproduce script and confirm that the error is fixed!\n'
'5. Think about edgecases and make sure your fix handles them as well\n'
'1. Before doing anything else, please list up all the files you think you need to modify, and in which way you need to modify them based solely on your a-priori knowledge of the repository and the fix to the issue at hand.'
'2. Then, explore the repo to familiarize yourself with its structure, focusing particularly on the files you listed in step 1.\n'
'3. Create a script to reproduce the error and execute it with `python <filename.py>` using the BashTool, to confirm the error\n'
'4. Edit the sourcecode of the repo to resolve the issue\n'
'5. Rerun your reproduce script and confirm that the error is fixed!\n'
'6. Think about edgecases and make sure your fix handles them as well\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
)

View File

@@ -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))

View File

@@ -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']))

View File

@@ -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')

View File

@@ -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 \

View File

@@ -1,50 +0,0 @@
# VisualWebArena Evaluation with OpenHands Browsing Agents
This folder contains evaluation for [VisualWebArena](https://github.com/web-arena-x/visualwebarena) benchmark, powered by [BrowserGym](https://github.com/ServiceNow/BrowserGym) for easy evaluation of how well an agent capable of browsing can perform on realistic web browsing tasks.
## Setup Environment and LLM Configuration
Please follow instruction [here](../../README.md#setup) to setup your local development environment and LLM.
## Setup VisualWebArena Environment
VisualWebArena requires you to set up websites containing pre-populated content that is accessible via URL to the machine running the OpenHands agents.
Follow [this document](https://github.com/web-arena-x/visualwebarena/blob/main/environment_docker/README.md) to set up your own VisualWebArena environment through local servers or AWS EC2 instances.
Take note of the base URL (`$VISUALWEBARENA_BASE_URL`) of the machine where the environment is installed.
## Test if your environment works
Access with browser the above VisualWebArena website URLs and see if they load correctly.
If you cannot access the website, make sure the firewall allows public access of the aforementioned ports on your server
Check the network security policy if you are using an AWS machine.
Follow the VisualWebArena environment setup guide carefully, and make sure the URL fields are populated with the correct base URL of your server.
## Run Evaluation
```bash
export VISUALWEBARENA_BASE_URL=<YOUR_SERVER_URL_HERE>
export OPENAI_API_KEY="yourkey" # this OpenAI API key is required for some visualWebArena validators that utilize LLMs
export OPENAI_BASE_URL="https://api.openai.com/v1/" # base URL for OpenAI model used for VisualWebArena evaluation
bash evaluation/benchmarks/visualwebarena/scripts/run_infer.sh llm.claude HEAD VisualBrowsingAgent
```
Results will be in `evaluation/evaluation_outputs/outputs/visualwebarena/`
To calculate the success rate, run:
```sh
poetry run python evaluation/benchmarks/visualwebarena/get_success_rate.py evaluation/evaluation_outputs/outputs/visualwebarena/SOME_AGENT/EXP_NAME/output.jsonl
```
## Submit your evaluation results
You can start your own fork of [our huggingface evaluation outputs](https://huggingface.co/spaces/OpenHands/evaluation) and submit a PR of your evaluation results following the guide [here](https://huggingface.co/docs/hub/en/repositories-pull-requests-discussions#pull-requests-and-discussions).
## VisualBrowsingAgent V1.0 result
Tested on VisualBrowsingAgent V1.0
VisualWebArena, 910 tasks (high cost, single run due to fixed task), max step 15. Resolve rates are:
- GPT4o: 26.15%
- Claude-3.5 Sonnet: 25.27%

View File

@@ -1,40 +0,0 @@
import argparse
import json
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
import gymnasium as gym
parser = argparse.ArgumentParser(description='Calculate average reward.')
parser.add_argument('output_path', type=str, help='path to output.jsonl')
args = parser.parse_args()
if __name__ == '__main__':
env_ids = [
id
for id in gym.envs.registry.keys()
if id.startswith('browsergym/visualwebarena')
]
total_num = len(env_ids)
print('Total number of tasks: ', total_num)
total_reward = 0
total_cost = 0
actual_num = 0
with open(args.output_path, 'r') as f:
for line in f:
data = json.loads(line)
actual_num += 1
total_cost += data['metrics']['accumulated_cost']
reward = data['test_result']['reward']
if reward >= 0:
total_reward += data['test_result']['reward']
else:
actual_num -= 1
avg_reward = total_reward / total_num
print('Total reward: ', total_reward)
print('Success Rate: ', avg_reward)
avg_cost = total_cost / actual_num
print('Avg Cost: ', avg_cost)
print('Total Cost: ', total_cost)
print('Actual number of tasks finished: ', actual_num)

View File

@@ -1,254 +0,0 @@
import asyncio
import json
import os
from typing import Any
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
import gymnasium as gym
import pandas as pd
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
update_llm_config_for_completions_logging,
)
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import (
BrowseInteractiveAction,
CmdRunAction,
MessageAction,
)
from openhands.events.observation import CmdOutputObservation
from openhands.runtime.base import Runtime
from openhands.runtime.browser.browser_env import (
BROWSER_EVAL_GET_GOAL_ACTION,
BROWSER_EVAL_GET_REWARDS_ACTION,
)
from openhands.utils.async_utils import call_async_from_sync
SUPPORTED_AGENT_CLS = {'VisualBrowsingAgent'}
AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
'VisualBrowsingAgent': 'Continue the task. IMPORTANT: do not talk to the user until you have finished the task',
}
def get_config(
metadata: EvalMetadata,
env_id: str,
) -> AppConfig:
base_url = os.environ.get('VISUALWEBARENA_BASE_URL', None)
openai_api_key = os.environ.get('OPENAI_API_KEY', None)
openai_base_url = os.environ.get('OPENAI_BASE_URL', None)
assert base_url is not None, 'VISUALWEBARENA_BASE_URL must be set'
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
assert openai_base_url is not None, 'OPENAI_BASE_URL must be set'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
browsergym_eval_env=env_id,
runtime_startup_env_vars={
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'OPENAI_BASE_URL': openai_base_url,
'VWA_CLASSIFIEDS': f'{base_url}:9980',
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
'VWA_SHOPPING': f'{base_url}:7770',
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
'VWA_REDDIT': f'{base_url}:9999',
'VWA_GITLAB': f'{base_url}:8023',
'VWA_WIKIPEDIA': f'{base_url}:8888',
'VWA_HOMEPAGE': f'{base_url}:4399',
},
timeout=300,
),
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
attach_to_existing=True,
)
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config,
metadata.eval_output_dir,
env_id,
)
)
return config
def initialize_runtime(
runtime: Runtime,
) -> tuple[str, list]:
"""Initialize the runtime for the agent.
This function is called before the runtime is used to run the agent.
"""
logger.info(f"{'-' * 50} BEGIN Runtime Initialization Fn {'-' * 50}")
obs: CmdOutputObservation
# Set instance id
action = CmdRunAction(command='mkdir -p /workspace')
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
assert obs.exit_code == 0
action = BrowseInteractiveAction(browser_actions=BROWSER_EVAL_GET_GOAL_ACTION)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
goal = obs.content
goal_image_urls = []
if hasattr(obs, 'goal_image_urls'):
goal_image_urls = obs.goal_image_urls
logger.info(f"{'-' * 50} END Runtime Initialization Fn {'-' * 50}")
return goal, goal_image_urls
def complete_runtime(
runtime: Runtime,
) -> dict[str, Any]:
"""Complete the runtime for the agent.
This function is called before the runtime is used to run the agent.
If you need to do something in the sandbox to get the correctness metric after
the agent has run, modify this function.
"""
logger.info(f"{'-' * 50} BEGIN Runtime Completion Fn {'-' * 50}")
obs: CmdOutputObservation
action = BrowseInteractiveAction(browser_actions=BROWSER_EVAL_GET_REWARDS_ACTION)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
logger.info(f"{'-' * 50} END Runtime Completion Fn {'-' * 50}")
return {
'rewards': json.loads(obs.content),
}
def process_instance(
instance: pd.Series,
metadata: EvalMetadata,
reset_logger: bool = True,
):
env_id = instance.instance_id
config = get_config(metadata, env_id)
# 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, env_id, log_dir)
else:
logger.info(f'Starting evaluation for instance {env_id}.')
runtime = create_runtime(config)
call_async_from_sync(runtime.connect)
task_str, goal_image_urls = initialize_runtime(runtime)
initial_user_action = MessageAction(content=task_str, image_urls=goal_image_urls)
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=initial_user_action,
runtime=runtime,
)
)
# ======= Attempt to evaluate the agent's environment impact =======
# If you are working on some simpler benchmark that only evaluates the final model output (e.g., in a MessageAction)
# You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation.
if state is None:
raise ValueError('State should not be None.')
metrics = state.metrics.get() if state.metrics else None
# Instruction obtained from the first message from the USER
instruction = ''
for event in state.history:
if isinstance(event, MessageAction):
instruction = event.content
break
try:
return_val = complete_runtime(runtime)
logger.info(f'Return value from complete_runtime: {return_val}')
reward = max(return_val['rewards'])
except Exception:
reward = -1.0 # kept -1 to identify instances for which evaluation failed.
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
# for compatibility with the existing output format, we can remake the pairs here
# remove when it becomes unnecessary
histories = compatibility_for_eval_history_pairs(state.history)
# Save the output
output = EvalOutput(
instance_id=env_id,
instruction=instruction,
metadata=metadata,
history=histories,
metrics=metrics,
error=state.last_error if state and state.last_error else None,
test_result={
'reward': reward,
},
)
runtime.close()
return output
if __name__ == '__main__':
args = parse_arguments()
dataset = pd.DataFrame(
{
'instance_id': [
id
for id in gym.envs.registry.keys()
if id.startswith('browsergym/visualwebarena')
]
}
)
llm_config = None
if args.llm_config:
llm_config = get_llm_config_arg(args.llm_config)
if llm_config is None:
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
metadata = make_metadata(
llm_config,
'visualwebarena',
args.agent_cls,
args.max_iterations,
args.eval_note,
args.eval_output_dir,
)
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
instances = prepare_dataset(dataset, output_file, args.eval_n_limit)
run_evaluation(
instances,
metadata,
output_file,
args.eval_num_workers,
process_instance,
)

View File

@@ -1,48 +0,0 @@
#!/bin/bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
# configure browsing agent
export USE_NAV="true"
export USE_CONCISE_ANSWER="true"
MODEL_CONFIG=$1
COMMIT_HASH=$2
AGENT=$3
EVAL_LIMIT=$4
NUM_WORKERS=$5
if [ -z "$NUM_WORKERS" ]; then
NUM_WORKERS=1
echo "Number of workers not specified, use default $NUM_WORKERS"
fi
checkout_eval_branch
if [ -z "$AGENT" ]; then
echo "Agent not specified, use default VisualBrowsingAgent"
AGENT="VisualBrowsingAgent"
fi
get_openhands_version
echo "AGENT: $AGENT"
echo "AGENT_VERSION: $OPENHANDS_VERSION"
echo "MODEL_CONFIG: $MODEL_CONFIG"
EVAL_NOTE="${OPENHANDS_VERSION}"
COMMAND="poetry run python evaluation/benchmarks/visualwebarena/run_infer.py \
--agent-cls $AGENT \
--llm-config $MODEL_CONFIG \
--max-iterations 15 \
--eval-num-workers $NUM_WORKERS \
--eval-note $EVAL_NOTE"
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
# Run the command
eval $COMMAND

View File

@@ -35,7 +35,6 @@ from openhands.utils.async_utils import call_async_from_sync
FAKE_RESPONSES = {
'CodeActAgent': fake_user_response,
'DelegatorAgent': fake_user_response,
'VisualBrowsingAgent': fake_user_response,
}

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
QueryClientProvider,
@@ -7,12 +7,10 @@ import {
} from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import React from "react";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { clickOnEditButton } from "./utils";
import { queryClientConfig } from "#/query-client-config";
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
@@ -233,47 +231,4 @@ describe("ConversationPanel", () => {
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should refetch data on rerenders", async () => {
// We need to simulate the toggling of the component to test the refetching
function PanelWithToggle() {
const [isOpen, setIsOpen] = React.useState(true);
return (
<>
<button type="button" onClick={() => setIsOpen((prev) => !prev)}>
Toggle
</button>
{isOpen && <ConversationPanel onClose={onCloseMock} />}
</>
);
}
const MyRouterStub = createRoutesStub([
{
Component: PanelWithToggle,
path: "/",
},
]);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
render(<MyRouterStub />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient(queryClientConfig)}>
{children}
</QueryClientProvider>
</AuthProvider>
),
});
await waitFor(() => expect(getUserConversationsSpy).toHaveBeenCalledOnce());
const button = screen.getByText("Toggle");
await userEvent.click(button);
await userEvent.click(button);
await waitFor(() =>
expect(getUserConversationsSpy).toHaveBeenCalledTimes(2),
);
});
});

View File

@@ -4,22 +4,48 @@ 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 renderSidebar = () => {
const RouterStub = createRoutesStub([
{
path: "/conversation/:conversationId",
Component: Sidebar,
},
]);
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 +73,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 +136,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
});
});
@@ -109,9 +152,7 @@ describe("Sidebar", () => {
const settingsModal = screen.getByTestId("ai-config-modal");
// Click the advanced options switch to show the API key input
const advancedOptionsSwitch = within(settingsModal).getByTestId(
"advanced-option-switch",
);
const advancedOptionsSwitch = within(settingsModal).getByTestId("advanced-option-switch");
await user.click(advancedOptionsSwitch);
const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);
@@ -123,40 +164,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();
});
});
});

View File

@@ -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);

View File

@@ -1,13 +1,12 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TrajectoryActions } from "#/components/features/trajectory/trajectory-actions";
import { FeedbackActions } from "#/components/features/feedback/feedback-actions";
describe("TrajectoryActions", () => {
describe("FeedbackActions", () => {
const user = userEvent.setup();
const onPositiveFeedback = vi.fn();
const onNegativeFeedback = vi.fn();
const onExportTrajectory = vi.fn();
afterEach(() => {
vi.clearAllMocks();
@@ -15,10 +14,9 @@ describe("TrajectoryActions", () => {
it("should render correctly", () => {
render(
<TrajectoryActions
<FeedbackActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -29,10 +27,9 @@ describe("TrajectoryActions", () => {
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
render(
<TrajectoryActions
<FeedbackActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -44,10 +41,9 @@ describe("TrajectoryActions", () => {
it("should call onNegativeFeedback when negative feedback is clicked", async () => {
render(
<TrajectoryActions
<FeedbackActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -56,19 +52,4 @@ describe("TrajectoryActions", () => {
expect(onNegativeFeedback).toHaveBeenCalled();
});
it("should call onExportTrajectory when negative feedback is clicked", async () => {
render(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});

View File

@@ -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: "",
});
});
});

View File

@@ -1,20 +1,20 @@
import { describe, it, expect } from "vitest";
import store from "../src/store";
import {
setInitialPrompt,
clearInitialPrompt,
setInitialQuery,
clearInitialQuery,
} from "../src/state/initial-query-slice";
describe("Initial Query Behavior", () => {
it("should clear initial query when clearInitialPrompt is dispatched", () => {
it("should clear initial query when clearInitialQuery is dispatched", () => {
// Set up initial query in the store
store.dispatch(setInitialPrompt("test query"));
expect(store.getState().initialQuery.initialPrompt).toBe("test query");
store.dispatch(setInitialQuery("test query"));
expect(store.getState().initialQuery.initialQuery).toBe("test query");
// Clear the initial query
store.dispatch(clearInitialPrompt());
store.dispatch(clearInitialQuery());
// Verify initial query is cleared
expect(store.getState().initialQuery.initialPrompt).toBeNull();
expect(store.getState().initialQuery.initialQuery).toBeNull();
});
});

View File

@@ -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" });
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.21.0",
"version": "0.20.0",
"private": true,
"type": "module",
"engines": {
@@ -9,25 +9,24 @@
"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",
"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 +35,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 +76,22 @@
},
"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",
"@testing-library/dom": "^10.4.0",
"@tanstack/eslint-plugin-query": "^5.64.2",
"@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 +105,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",

View File

@@ -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,
};

View File

@@ -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,14 +242,12 @@ 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,
};
const { data } = await openHands.post<Conversation>(
@@ -347,10 +364,6 @@ class OpenHands {
);
return data;
}
static async logout(): Promise<void> {
await openHands.post("/api/logout");
}
}
export default OpenHands;

View File

@@ -23,7 +23,7 @@ export const AGENT_STATUS_MAP: {
},
[AgentState.AWAITING_USER_INPUT]: {
message: I18nKey.CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE,
indicator: IndicatorColor.BLUE,
indicator: IndicatorColor.ORANGE,
},
[AgentState.PAUSED]: {
message: I18nKey.CHAT_INTERFACE$AGENT_PAUSED_MESSAGE,

View File

@@ -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 ? (
<>

View File

@@ -4,7 +4,8 @@ import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { FeedbackActions } from "../feedback/feedback-actions";
import { ExportActions } from "../export/export-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { addUserMessage } from "#/state/chat-slice";
@@ -154,13 +155,15 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<TrajectoryActions
<FeedbackActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
/>
<ExportActions
onExportTrajectory={() => onClickExportTrajectoryButton()}
/>

View File

@@ -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,

View File

@@ -12,22 +12,15 @@ interface MessagesProps {
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) =>
messages.map((message, index) => {
const shouldShowConfirmationButtons =
messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation;
if (message.type === "error" || message.type === "action") {
return (
<div key={index}>
<ExpandableMessage
type={message.type}
id={message.translationID}
message={message.content}
success={message.success}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
<ExpandableMessage
key={index}
type={message.type}
id={message.translationID}
message={message.content}
success={message.success}
/>
);
}
@@ -40,7 +33,9 @@ export const Messages: React.FC<MessagesProps> = React.memo(
{message.imageUrls && message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
{messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation && <ConfirmationButtons />}
</ChatMessage>
);
}),

View File

@@ -43,7 +43,7 @@ export function AgentStatusBar() {
React.useEffect(() => {
if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage("Connecting...");
setStatusMessage("Trying to reconnect...");
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
}

View File

@@ -0,0 +1,17 @@
import ExportIcon from "#/icons/export.svg?react";
import { ExportActionButton } from "#/components/shared/buttons/export-action-button";
interface ExportActionsProps {
onExportTrajectory: () => void;
}
export function ExportActions({ onExportTrajectory }: ExportActionsProps) {
return (
<div data-testid="export-actions" className="flex gap-1">
<ExportActionButton
onClick={onExportTrajectory}
icon={<ExportIcon width={15} height={15} />}
/>
</div>
);
}

View File

@@ -1,36 +1,28 @@
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
import ExportIcon from "#/icons/export.svg?react";
import { TrajectoryActionButton } from "#/components/shared/buttons/trajectory-action-button";
import { FeedbackActionButton } from "#/components/shared/buttons/feedback-action-button";
interface TrajectoryActionsProps {
interface FeedbackActionsProps {
onPositiveFeedback: () => void;
onNegativeFeedback: () => void;
onExportTrajectory: () => void;
}
export function TrajectoryActions({
export function FeedbackActions({
onPositiveFeedback,
onNegativeFeedback,
onExportTrajectory,
}: TrajectoryActionsProps) {
}: FeedbackActionsProps) {
return (
<div data-testid="feedback-actions" className="flex gap-1">
<TrajectoryActionButton
<FeedbackActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
/>
<TrajectoryActionButton
<FeedbackActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
/>
<TrajectoryActionButton
testId="export-trajectory"
onClick={onExportTrajectory}
icon={<ExportIcon width={15} height={15} />}
/>
</div>
);
}

View File

@@ -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>
)}
</>
);

View File

@@ -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)}
/>
))}
</>
);
}

View File

@@ -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>
);

View File

@@ -0,0 +1,17 @@
interface ExportActionButtonProps {
onClick: () => void;
icon: React.ReactNode;
}
export function ExportActionButton({ onClick, icon }: ExportActionButtonProps) {
return (
<button
type="button"
onClick={onClick}
className="button-base p-1 hover:bg-neutral-500"
title="Export trajectory"
>
{icon}
</button>
);
}

View File

@@ -1,14 +1,14 @@
interface TrajectoryActionButtonProps {
interface FeedbackActionButtonProps {
testId?: string;
onClick: () => void;
icon: React.ReactNode;
}
export function TrajectoryActionButton({
export function FeedbackActionButton({
testId,
onClick,
icon,
}: TrajectoryActionButtonProps) {
}: FeedbackActionButtonProps) {
return (
<button
type="button"

View File

@@ -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"
/>
)}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -11,11 +11,15 @@ import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux";
import posthog from "posthog-js";
import "./i18n";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import toast from "react-hot-toast";
import store from "./store";
import { useConfig } from "./hooks/query/use-config";
import { AuthProvider } from "./context/auth-context";
import { queryClientConfig } from "./query-client-config";
import { SettingsProvider } from "./context/settings-context";
function PosthogInit() {
@@ -46,7 +50,27 @@ async function prepareApp() {
}
}
const queryClient = new QueryClient(queryClientConfig);
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
toast.error(error.message);
}
},
}),
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
},
mutations: {
onError: (error) => {
toast.error(error.message);
},
},
},
});
prepareApp().then(() =>
startTransition(() => {

View File

@@ -3,12 +3,14 @@ import { useNavigate } from "react-router";
import posthog from "posthog-js";
import { useDispatch, useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { setInitialQuery } 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(
@@ -16,7 +18,7 @@ export const useCreateConversation = () => {
);
return useMutation({
mutationFn: async (variables: { q?: string }) => {
mutationFn: (variables: { q?: string }) => {
if (
!variables.q?.trim() &&
!selectedRepository &&
@@ -26,12 +28,10 @@ export const useCreateConversation = () => {
throw new Error("No query provided");
}
if (variables.q) dispatch(setInitialPrompt(variables.q));
if (variables.q) dispatch(setInitialQuery(variables.q));
return OpenHands.createConversation(
gitHubToken || undefined,
selectedRepository || undefined,
variables.q,
files,
);
},
onSuccess: async ({ conversation_id: conversationId }, { q }) => {

View File

@@ -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();
},
});
};

View File

@@ -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);

View File

@@ -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",
});

View File

@@ -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",

View File

@@ -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 } = 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,20 +29,5 @@ 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();
}
}, [user.isError]);
return user;
};

View File

@@ -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

View File

@@ -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;
};

View File

@@ -9,6 +9,5 @@ export const useUserConversations = () => {
queryKey: ["user", "conversations"],
queryFn: OpenHands.getUserConversations,
enabled: !!userIsAuthenticated,
staleTime: 0,
});
};

View File

@@ -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

View File

@@ -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]);

View File

@@ -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();
}
}, []);
};

View File

@@ -4506,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"
}
}

View File

@@ -1 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 16" fill="none">
<path
d="M11.875 9.5h-2.5V3.25c0-.16576-.0658-.32473-.1831-.44194-.1172-.11721-.276-.18306-.4419-.18306h-2.5c-.16576 0-.32473.06585-.44194.18306C5.68585 2.92527 5.62 3.08424 5.62 3.25V9.5h-2.5c-.13855 0-.27293.0483-.38002.1367-.10708.0883-.18294.2124-.21493.3508-.03199.1385-.01839.2839.03873.4142.05712.1304.15543.2397.27872.3108l4.375 2.5c.09664.0552.20607.0842.3175.0842.11144 0 .22087-.029.3175-.0842l4.375-2.5c.1233-.0711.2216-.1804.2787-.3108.0571-.1303.0707-.2757.0387-.4142-.032-.1384-.1078-.2625-.2149-.3508-.1071-.0884-.2415-.1367-.38-.1367zM3.75 13.375v1.25c0 .1658.06585.3247.18306.4419.11721.1172.27618.1831.44194.1831h6.25c.1657 0 .3247-.0659.4419-.1831.1172-.1172.1831-.2761.1831-.4419v-1.25c0-.1657-.0659-.3247-.1831-.4419-.1172-.1172-.2762-.1831-.4419-.1831h-6.25c-.16576 0-.32473.0659-.44194.1831C3.81585 13.0503 3.75 13.2093 3.75 13.375z"
fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 347 B

After

Width:  |  Height:  |  Size: 963 B

View File

@@ -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[] = [
@@ -150,7 +141,7 @@ export const handlers = [
{ id: 2, full_name: "octocat/earth" },
]),
),
http.get("/api/github/user", () => {
http.get("https://api.github.com/user", () => {
const user: GitHubUser = {
id: 1,
login: "octocat",
@@ -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 });

View File

@@ -1,25 +0,0 @@
import { QueryClientConfig, QueryCache } from "@tanstack/react-query";
import { renderToastIfError } from "./utils/render-toast-if-error";
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts", "settings"];
export const queryClientConfig: QueryClientConfig = {
queryCache: new QueryCache({
onError: (error, query) => {
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
renderToastIfError(error);
}
},
}),
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
},
mutations: {
onError: (error) => {
renderToastIfError(error);
},
},
},
};

View File

@@ -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,
});

View File

@@ -1,8 +1,10 @@
import React from "react";
import { useWSStatusChange } from "./hooks/use-ws-status-change";
import { useHandleWSEvents } from "./hooks/use-handle-ws-events";
import { useHandleRuntimeActive } from "./hooks/use-handle-runtime-active";
export function EventHandler({ children }: React.PropsWithChildren) {
useWSStatusChange();
useHandleWSEvents();
useHandleRuntimeActive();

View File

@@ -0,0 +1,68 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { createChatMessage } from "#/services/chat-service";
import { setCurrentAgentState } from "#/state/agent-slice";
import { addUserMessage } from "#/state/chat-slice";
import { clearFiles, clearInitialQuery } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
export const useWSStatusChange = () => {
const { send, status } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const dispatch = useDispatch();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const { files, initialQuery } = useSelector(
(state: RootState) => state.initialQuery,
);
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const dispatchInitialQuery = (query: string) => {
sendInitialQuery(query, files);
dispatch(clearFiles()); // reset selected files
dispatch(clearInitialQuery()); // reset initial query
};
const handleAgentInit = () => {
if (initialQuery) {
dispatchInitialQuery(initialQuery);
}
};
React.useEffect(() => {
if (curAgentState === AgentState.INIT) {
handleAgentInit();
}
}, [curAgentState]);
React.useEffect(() => {
if (statusRef.current === status) {
return; // This is a check because of strict mode - if the status did not change, don't do anything
}
statusRef.current = status;
if (status !== WsClientProviderStatus.DISCONNECTED && initialQuery) {
dispatch(
addUserMessage({
content: initialQuery,
imageUrls: files,
timestamp: new Date().toISOString(),
pending: true,
}),
);
}
if (status === WsClientProviderStatus.DISCONNECTED) {
dispatch(setCurrentAgentState(AgentState.STOPPED));
}
}, [status]);
};

View File

@@ -1,7 +1,7 @@
import { useDisclosure } from "@nextui-org/react";
import React from "react";
import { Outlet } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { FaServer } from "react-icons/fa";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -11,7 +11,7 @@ import {
useConversation,
} from "#/context/conversation-context";
import { Controls } from "#/components/features/controls/controls";
import { clearMessages, addUserMessage } from "#/state/chat-slice";
import { clearMessages } from "#/state/chat-slice";
import { clearTerminal } from "#/state/command-slice";
import { useEffectOnce } from "#/hooks/use-effect-once";
import CodeIcon from "#/icons/code.svg?react";
@@ -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 {
@@ -35,29 +36,24 @@ import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { useSettings } from "#/hooks/query/use-settings";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import { clearFiles, clearInitialPrompt } from "#/state/initial-query-slice";
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(
conversationId || null,
);
const { initialPrompt, files } = useSelector(
(state: RootState) => state.initialQuery,
);
const dispatch = useDispatch();
const endSession = useEndSession();
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(
@@ -78,18 +74,6 @@ function AppContent() {
dispatch(clearMessages());
dispatch(clearTerminal());
dispatch(clearJupyter());
if (conversationId && (initialPrompt || files.length > 0)) {
dispatch(
addUserMessage({
content: initialPrompt || "",
imageUrls: files || [],
timestamp: new Date().toISOString(),
pending: true,
}),
);
dispatch(clearInitialPrompt());
dispatch(clearFiles());
}
}, [conversationId]);
useEffectOnce(() => {

View File

@@ -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 && (

View File

@@ -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]);

View File

@@ -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();

View File

@@ -2,14 +2,14 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type SliceState = {
files: string[]; // base64 encoded images
initialPrompt: string | null;
initialQuery: string | null;
selectedRepository: string | null;
importedProjectZip: string | null; // base64 encoded zip
};
const initialState: SliceState = {
files: [],
initialPrompt: null,
initialQuery: null,
selectedRepository: null,
importedProjectZip: null,
};
@@ -27,11 +27,11 @@ export const selectedFilesSlice = createSlice({
clearFiles(state) {
state.files = [];
},
setInitialPrompt(state, action: PayloadAction<string>) {
state.initialPrompt = action.payload;
setInitialQuery(state, action: PayloadAction<string>) {
state.initialQuery = action.payload;
},
clearInitialPrompt(state) {
state.initialPrompt = null;
clearInitialQuery(state) {
state.initialQuery = null;
},
setSelectedRepository(state, action: PayloadAction<string | null>) {
state.selectedRepository = action.payload;
@@ -49,8 +49,8 @@ export const {
addFile,
removeFile,
clearFiles,
setInitialPrompt,
clearInitialPrompt,
setInitialQuery,
clearInitialQuery,
setSelectedRepository,
clearSelectedRepository,
setImportedProjectZip,

View File

@@ -1,8 +0,0 @@
import "@tanstack/react-query";
import type { AxiosError } from "axios";
declare module "@tanstack/react-query" {
interface Register {
defaultError: AxiosError;
}
}

View File

@@ -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;
};

View File

@@ -13,4 +13,3 @@ function loadFeatureFlag(
}
export const MULTI_CONVERSATION_UI = loadFeatureFlag("MULTI_CONVERSATION_UI");
export const MEMORY_CONDENSER = loadFeatureFlag("MEMORY_CONDENSER");

View File

@@ -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");
};

View File

@@ -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 };

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