mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
move-githu
...
rb/err-han
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473fea06c9 |
@@ -18,24 +18,24 @@ diverse, inclusive, and healthy community.
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people.
|
||||
* Being respectful of differing opinions, viewpoints, and experiences.
|
||||
* Giving and gracefully accepting constructive feedback.
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience.
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community.
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind.
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks.
|
||||
* Public or private harassment.
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission.
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting.
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
@@ -61,7 +61,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
contact@all-hands.dev.
|
||||
contact@all-hands.dev
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -11,11 +11,11 @@ To understand the codebase, please refer to the README in each module:
|
||||
- [agenthub](./openhands/agenthub/README.md)
|
||||
- [server](./openhands/server/README.md)
|
||||
|
||||
## Setting up Your Development Environment
|
||||
## Setting up your development environment
|
||||
|
||||
We have a separate doc [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md) that tells you how to set up a development workflow.
|
||||
|
||||
## How Can I Contribute?
|
||||
## How can I contribute?
|
||||
|
||||
There are many ways that you can contribute:
|
||||
|
||||
@@ -23,7 +23,7 @@ There are many ways that you can contribute:
|
||||
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.all-hands.dev/modules/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
|
||||
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/All-Hands-AI/OpenHands/labels/good%20first%20issue) that may be ones to start on.
|
||||
|
||||
## What Can I Build?
|
||||
## What can I build?
|
||||
Here are a few ways you can help improve the codebase.
|
||||
|
||||
#### UI/UX
|
||||
@@ -35,7 +35,7 @@ of the application, please open an issue first, or better, join the #frontend ch
|
||||
to gather consensus from our design team first.
|
||||
|
||||
#### Improving the agent
|
||||
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/agenthub/codeact_agent).
|
||||
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/agenthub/codeact_agent)
|
||||
|
||||
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
|
||||
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
|
||||
@@ -63,7 +63,7 @@ At the moment, we have two kinds of tests: [`unit`](./tests/unit) and [`integrat
|
||||
## Sending Pull Requests to OpenHands
|
||||
|
||||
You'll need to fork our repository to send us a Pull Request. You can learn more
|
||||
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
|
||||
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8)
|
||||
|
||||
### Pull Request title
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
|
||||
@@ -103,7 +103,7 @@ Further, if you see an issue you like, please leave a "thumbs-up" or a comment,
|
||||
|
||||
### Making Pull Requests
|
||||
|
||||
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
|
||||
We're generally happy to consider all [PRs](https://github.com/All-Hands-AI/OpenHands/pulls), with the evaluation process varying based on the type of change:
|
||||
|
||||
#### For Small Improvements
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ This guide is for people working on OpenHands and editing the source code.
|
||||
If you wish to contribute your changes, check out the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md) on how to clone and setup the project initially before moving on.
|
||||
Otherwise, you can clone the OpenHands project directly.
|
||||
|
||||
## Start the Server for Development
|
||||
## Start the server for development
|
||||
### 1. Requirements
|
||||
* Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu <= 22.04]
|
||||
* [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
|
||||
@@ -58,7 +58,7 @@ See [our documentation](https://docs.all-hands.dev/modules/usage/llms) for recom
|
||||
|
||||
### 4. Running the application
|
||||
#### Option A: Run the Full Application
|
||||
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
|
||||
Once the setup is complete, launching OpenHands is as simple as running a single command. This command starts both the backend and frontend servers seamlessly, allowing you to interact with OpenHands:
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
@@ -75,11 +75,11 @@ make run
|
||||
```
|
||||
|
||||
### 6. LLM Debugging
|
||||
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
|
||||
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
|
||||
If you encounter any issues with the Language Model (LM) or you're simply curious, you can inspect the actual LLM prompts and responses. To do so, export DEBUG=1 in the environment and restart the backend.
|
||||
OpenHands will then log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
|
||||
|
||||
### 7. Help
|
||||
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
|
||||
Need assistance or information on available targets and commands? The help command provides all the necessary guidance to ensure a smooth experience with OpenHands.
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
@@ -93,14 +93,14 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
```
|
||||
|
||||
### 9. Add or update dependency
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
|
||||
2. Update the poetry.lock file via `poetry lock --no-update`.
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
|
||||
2. Update the poetry.lock file via `poetry lock --no-update`
|
||||
|
||||
### 9. Use existing Docker image
|
||||
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.19-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.18-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
@@ -110,7 +110,7 @@ TL;DR
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
See more details [here](./containers/dev/README.md).
|
||||
See more details [here](./containers/dev/README.md)
|
||||
|
||||
If you are just interested in running `OpenHands` without installing all the required tools on your host.
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
These are the procedures and guidelines on how issues are triaged in this repo by the maintainers.
|
||||
|
||||
## General
|
||||
* Most issues must be tagged with **enhancement** or **bug**.
|
||||
* Issues may be tagged with what it relates to (**backend**, **frontend**, **agent quality**, etc.).
|
||||
* Most issues must be tagged with **enhancement** or **bug**
|
||||
* Issues may be tagged with what it relates to (**backend**, **frontend**, **agent quality**, etc.)
|
||||
|
||||
## Severity
|
||||
* **Low**: Minor issues or affecting single user.
|
||||
@@ -11,10 +11,10 @@ These are the procedures and guidelines on how issues are triaged in this repo b
|
||||
* **Critical**: Affecting all users or potential security issues.
|
||||
|
||||
## Effort
|
||||
* Issues may be estimated with effort required (**small effort**, **medium effort**, **large effort**).
|
||||
* Issues may be estimated with effort required (**small effort**, **medium effort**, **large effort**)
|
||||
|
||||
## Difficulty
|
||||
* Issues with low implementation difficulty may be tagged with **good first issue**.
|
||||
* Issues with low implementation difficulty may be tagged with **good first issue**
|
||||
|
||||
## Not Enough Information
|
||||
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.
|
||||
|
||||
@@ -43,17 +43,17 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
|
||||
system requirements and more information.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.19-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -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.19-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
La façon la plus simple d'exécuter OpenHands est avec Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
```
|
||||
|
||||
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.19
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
# Repository Micro-Agents
|
||||
# Customizing Agent Behavior
|
||||
|
||||
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context
|
||||
and guidelines. This section explains how to optimize OpenHands for your project.
|
||||
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context and guidelines. This section explains how to optimize OpenHands for your project.
|
||||
|
||||
## Repository Configuration
|
||||
|
||||
You can customize OpenHands' behavior for your repository by creating a `.openhands/microagents/` directory in your repository's root.
|
||||
At minimum, it should contain the file
|
||||
You can customize OpenHands' behavior for your repository by creating a `.openhands` directory in your repository's root. At minimum, it should contain the file
|
||||
`.openhands/microagents/repo.md`, which includes instructions that will
|
||||
be given to the agent every time it works with this repository.
|
||||
|
||||
@@ -41,8 +39,7 @@ Guidelines:
|
||||
|
||||
### Customizing Prompts
|
||||
|
||||
You may also add customized prompts to the `.openhands/microagents/repo.md` file when working with a repository.
|
||||
These could:
|
||||
When working with a repository:
|
||||
|
||||
- **Reference Project Standards**: Mention specific coding standards or patterns used in your project.
|
||||
- **Include Context**: Reference relevant documentation or existing implementations.
|
||||
@@ -57,10 +54,14 @@ The component should use our shared styling from src/styles/components.
|
||||
|
||||
### Best Practices for Repository Customization
|
||||
|
||||
- **Keep Instructions Updated**: Regularly update your `.openhands/microagents/` directory as your project evolves.
|
||||
- **Keep Instructions Updated**: Regularly update your `.openhands` directory as your project evolves.
|
||||
- **Be Specific**: Include specific paths, patterns, and requirements unique to your project.
|
||||
- **Document Dependencies**: List all tools and dependencies required for development.
|
||||
- **Include Examples**: Provide examples of good code patterns from your project.
|
||||
- **Specify Conventions**: Document naming conventions, file organization, and code style preferences.
|
||||
|
||||
By customizing OpenHands for your repository, you'll get more accurate and consistent results that align with your project's standards and requirements.
|
||||
|
||||
## Other Microagents
|
||||
You can create other instructions in the `.openhands/microagents/` directory
|
||||
that will be sent to the agent if a particular keyword is found, like `test`, `frontend`, or `migration`. See [Micro-Agents](microagents.md) for more information.
|
||||
@@ -1,31 +1,17 @@
|
||||
# Public Micro-Agents
|
||||
# Micro-Agents
|
||||
|
||||
OpenHands uses specialized micro-agents to handle specific tasks and contexts efficiently. These micro-agents are small,
|
||||
focused components that provide specialized behavior and knowledge for particular scenarios.
|
||||
OpenHands uses specialized micro-agents to handle specific tasks and contexts efficiently. These micro-agents are small, focused components that provide specialized behavior and knowledge for particular scenarios.
|
||||
|
||||
## Overview
|
||||
|
||||
Public micro-agents are defined in markdown files under the
|
||||
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) directory.
|
||||
Each micro-agent is configured with:
|
||||
Micro-agents are defined in markdown files under the `openhands/agenthub/codeact_agent/micro/` directory. Each micro-agent is configured with:
|
||||
|
||||
- A unique name.
|
||||
- The agent type (typically CodeActAgent).
|
||||
- Trigger keywords that activate the agent.
|
||||
- Specific instructions and capabilities.
|
||||
|
||||
### Integration
|
||||
|
||||
Public micro-agents are automatically integrated into OpenHands' workflow. They:
|
||||
- Monitor incoming commands for their trigger words.
|
||||
- Activate when relevant triggers are detected.
|
||||
- Apply their specialized knowledge and capabilities.
|
||||
- Follow their specific guidelines and restrictions.
|
||||
|
||||
## Available Public Micro-Agents
|
||||
|
||||
For more information about specific micro-agents, refer to their individual documentation files in
|
||||
the [`micro-agents`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents) directory.
|
||||
## Available Micro-Agents
|
||||
|
||||
### GitHub Agent
|
||||
**File**: `github.md`
|
||||
@@ -43,14 +29,6 @@ Key features:
|
||||
- Git configuration management
|
||||
- API-first approach for GitHub operations
|
||||
|
||||
Usage Example:
|
||||
|
||||
```bash
|
||||
git checkout -b feature-branch
|
||||
git commit -m "Add new feature"
|
||||
git push origin feature-branch
|
||||
```
|
||||
|
||||
### NPM Agent
|
||||
**File**: `npm.md`
|
||||
**Triggers**: `npm`
|
||||
@@ -60,15 +38,9 @@ Specializes in handling npm package management with specific focus on:
|
||||
- Automated confirmation handling using Unix 'yes' command.
|
||||
- Package installation automation.
|
||||
|
||||
Usage Example:
|
||||
### Custom Micro-Agents
|
||||
|
||||
```bash
|
||||
yes | npm install package-name
|
||||
```
|
||||
|
||||
### Custom Public Micro-Agents
|
||||
|
||||
You can create your own public micro-agents by adding new markdown files to the `microagents/knowledge/` directory.
|
||||
You can create your own micro-agents by adding new markdown files to the micro-agents directory.
|
||||
Each file should follow this structure:
|
||||
|
||||
```markdown
|
||||
@@ -83,29 +55,43 @@ triggers:
|
||||
Instructions and capabilities for the micro-agent...
|
||||
```
|
||||
|
||||
## Working With Public Micro-Agents
|
||||
## Best Practices
|
||||
|
||||
When working with public micro-agents:
|
||||
When working with micro-agents:
|
||||
- **Use Appropriate Triggers**: Ensure your commands include the relevant trigger words to activate the correct micro-agent.
|
||||
- **Follow Agent Guidelines**: Each agent has specific instructions and limitations. Respect these for optimal results.
|
||||
- **API-First Approach**: When available, use API endpoints rather than web interfaces.
|
||||
- **Automation Friendly**: Design commands that work well in non-interactive environments.
|
||||
|
||||
## Contributing a Public Micro-Agent
|
||||
## Integration
|
||||
|
||||
Best practices for creating public micro-agents:
|
||||
Micro-agents are automatically integrated into OpenHands' workflow. They:
|
||||
- Monitor incoming commands for their trigger words.
|
||||
- Activate when relevant triggers are detected.
|
||||
- Apply their specialized knowledge and capabilities.
|
||||
- Follow their specific guidelines and restrictions.
|
||||
|
||||
- **Clear Scope**: Keep the micro-agent focused on a specific domain or task.
|
||||
- **Explicit Instructions**: Provide clear, unambiguous guidelines.
|
||||
- **Useful Examples**: Include practical examples of common use cases.
|
||||
- **Safety First**: Include necessary warnings and constraints.
|
||||
- **Integration Awareness**: Consider how the micro-agent interacts with other components.
|
||||
## Example Usage
|
||||
|
||||
To contribute a new micro-agent to OpenHands:
|
||||
```bash
|
||||
# GitHub agent example
|
||||
git checkout -b feature-branch
|
||||
git commit -m "Add new feature"
|
||||
git push origin feature-branch
|
||||
|
||||
### 1. Plan the Public Micro-Agent
|
||||
# NPM agent example
|
||||
yes | npm install package-name
|
||||
```
|
||||
|
||||
Before creating a public micro-agent, consider:
|
||||
For more information about specific agents, refer to their individual documentation files in the micro-agents directory.
|
||||
|
||||
## Contributing a Micro-Agent
|
||||
|
||||
To contribute a new micro-agent to OpenHands, follow these guidelines:
|
||||
|
||||
### 1. Planning Your Micro-Agent
|
||||
|
||||
Before creating a micro-agent, consider:
|
||||
- What specific problem or use case will it address?
|
||||
- What unique capabilities or knowledge should it have?
|
||||
- What trigger words make sense for activating it?
|
||||
@@ -113,11 +99,11 @@ Before creating a public micro-agent, consider:
|
||||
|
||||
### 2. File Structure
|
||||
|
||||
Create a new markdown file in `microagents/knowledge/` with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
|
||||
Create a new markdown file in `openhands/agenthub/codeact_agent/micro/` with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
|
||||
|
||||
### 3. Required Components
|
||||
|
||||
The micro-agent file must include:
|
||||
Your micro-agent file must include:
|
||||
|
||||
- **Front Matter**: YAML metadata at the start of the file:
|
||||
```markdown
|
||||
@@ -147,7 +133,15 @@ Examples of usage:
|
||||
[Example 2]
|
||||
```
|
||||
|
||||
### 4. Testing the Public Micro-Agent
|
||||
### 4. Best Practices for Micro-Agent Development
|
||||
|
||||
- **Clear Scope**: Keep the agent focused on a specific domain or task.
|
||||
- **Explicit Instructions**: Provide clear, unambiguous guidelines.
|
||||
- **Useful Examples**: Include practical examples of common use cases.
|
||||
- **Safety First**: Include necessary warnings and constraints.
|
||||
- **Integration Awareness**: Consider how the agent interacts with other components.
|
||||
|
||||
### 5. Testing Your Micro-Agent
|
||||
|
||||
Before submitting:
|
||||
- Test the agent with various prompts.
|
||||
@@ -155,14 +149,7 @@ Before submitting:
|
||||
- Ensure instructions are clear and comprehensive.
|
||||
- Check for potential conflicts with existing agents.
|
||||
|
||||
### 5. Submission Process
|
||||
|
||||
Submit a pull request with:
|
||||
- The new micro-agent file.
|
||||
- Updated documentation if needed.
|
||||
- Description of the agent's purpose and capabilities.
|
||||
|
||||
### Example Public Micro-Agent Implementation
|
||||
### 6. Example Implementation
|
||||
|
||||
Here's a template for a new micro-agent:
|
||||
|
||||
@@ -210,5 +197,14 @@ Remember to:
|
||||
- Optimize for build time and image size
|
||||
```
|
||||
|
||||
### 7. Submission Process
|
||||
|
||||
1. Create your micro-agent file in the correct directory.
|
||||
2. Test thoroughly.
|
||||
3. Submit a pull request with:
|
||||
- The new micro-agent file.
|
||||
- Updated documentation if needed.
|
||||
- Description of the agent's purpose and capabilities.
|
||||
|
||||
Remember that micro-agents are a powerful way to extend OpenHands' capabilities in specific domains. Well-designed
|
||||
agents can significantly improve the system's ability to handle specialized tasks.
|
||||
@@ -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.19-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
1318
docs/package-lock.json
generated
1318
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,10 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/plugin-content-pages": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.7.0",
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@docusaurus/core": "^3.6.3",
|
||||
"@docusaurus/plugin-content-pages": "^3.6.3",
|
||||
"@docusaurus/preset-classic": "^3.6.3",
|
||||
"@docusaurus/theme-mermaid": "^3.6.3",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/tsconfig": "^3.6.3",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.7.2"
|
||||
},
|
||||
|
||||
@@ -23,21 +23,15 @@ const sidebars: SidebarsConfig = {
|
||||
id: 'usage/prompting/prompting-best-practices',
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Micro-Agents',
|
||||
items: [
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Public',
|
||||
id: 'usage/prompting/microagents-public',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Repository',
|
||||
id: 'usage/prompting/microagents-repo',
|
||||
},
|
||||
],
|
||||
}
|
||||
type: 'doc',
|
||||
label: 'Customization',
|
||||
id: 'usage/prompting/customization',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Microagents',
|
||||
id: 'usage/prompting/microagents',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -204,7 +204,7 @@ Then, in a separate Python environment with `streamlit` library, you can run the
|
||||
```bash
|
||||
# Make sure you are inside the cloned `evaluation` repo
|
||||
conda activate streamlit # if you follow the optional conda env setup above
|
||||
streamlit run app.py --server.port 8501 --server.address 0.0.0.0
|
||||
streamlit app.py --server.port 8501 --server.address 0.0.0.0
|
||||
```
|
||||
|
||||
Then you can access the SWE-Bench trajectory visualizer at `localhost:8501`.
|
||||
|
||||
@@ -15,7 +15,6 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
get_metrics,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
@@ -149,7 +148,6 @@ def get_config(
|
||||
codeact_enable_jupyter=False,
|
||||
codeact_enable_browsing=RUN_WITH_BROWSING,
|
||||
codeact_enable_llm_editor=False,
|
||||
condenser=metadata.condenser_config,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
@@ -450,7 +448,7 @@ def process_instance(
|
||||
|
||||
# NOTE: this is NO LONGER the event stream, but an agent history that includes delegate agent's events
|
||||
histories = [event_to_dict(event) for event in state.history]
|
||||
metrics = get_metrics(state)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -17,10 +17,6 @@ from tqdm import tqdm
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.config.condenser_config import (
|
||||
CondenserConfig,
|
||||
NoOpCondenserConfig,
|
||||
)
|
||||
from openhands.core.exceptions import (
|
||||
AgentRuntimeBuildError,
|
||||
AgentRuntimeDisconnectedError,
|
||||
@@ -37,7 +33,6 @@ from openhands.events.action.message import MessageAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.events.utils import get_pairs_from_events
|
||||
from openhands.memory.condenser import get_condensation_metadata
|
||||
|
||||
|
||||
class EvalMetadata(BaseModel):
|
||||
@@ -50,17 +45,11 @@ class EvalMetadata(BaseModel):
|
||||
dataset: str | None = None
|
||||
data_split: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
condenser_config: CondenserConfig | None = None
|
||||
|
||||
def model_dump(self, *args, **kwargs):
|
||||
dumped_dict = super().model_dump(*args, **kwargs)
|
||||
# avoid leaking sensitive information
|
||||
dumped_dict['llm_config'] = self.llm_config.to_safe_dict()
|
||||
if hasattr(self.condenser_config, 'llm_config'):
|
||||
dumped_dict['condenser_config']['llm_config'] = (
|
||||
self.condenser_config.llm_config.to_safe_dict()
|
||||
)
|
||||
|
||||
return dumped_dict
|
||||
|
||||
def model_dump_json(self, *args, **kwargs):
|
||||
@@ -68,11 +57,6 @@ class EvalMetadata(BaseModel):
|
||||
dumped_dict = json.loads(dumped)
|
||||
# avoid leaking sensitive information
|
||||
dumped_dict['llm_config'] = self.llm_config.to_safe_dict()
|
||||
if hasattr(self.condenser_config, 'llm_config'):
|
||||
dumped_dict['condenser_config']['llm_config'] = (
|
||||
self.condenser_config.llm_config.to_safe_dict()
|
||||
)
|
||||
|
||||
logger.debug(f'Dumped metadata: {dumped_dict}')
|
||||
return json.dumps(dumped_dict)
|
||||
|
||||
@@ -208,7 +192,6 @@ def make_metadata(
|
||||
eval_output_dir: str,
|
||||
data_split: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
condenser_config: CondenserConfig | None = None,
|
||||
) -> EvalMetadata:
|
||||
model_name = llm_config.model.split('/')[-1]
|
||||
model_path = model_name.replace(':', '_').replace('@', '-')
|
||||
@@ -239,9 +222,6 @@ def make_metadata(
|
||||
dataset=dataset_name,
|
||||
data_split=data_split,
|
||||
details=details,
|
||||
condenser_config=condenser_config
|
||||
if condenser_config
|
||||
else NoOpCondenserConfig(),
|
||||
)
|
||||
metadata_json = metadata.model_dump_json()
|
||||
logger.info(f'Metadata: {metadata_json}')
|
||||
@@ -571,10 +551,3 @@ def is_fatal_evaluation_error(error: str | None) -> bool:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_metrics(state: State) -> dict[str, Any]:
|
||||
"""Extract metrics from the state."""
|
||||
metrics = state.metrics.get() if state.metrics else {}
|
||||
metrics['condenser'] = get_condensation_metadata(state)
|
||||
return metrics
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { retrieveLatestGitHubCommit } from "../../src/api/github";
|
||||
|
||||
describe("retrieveLatestGitHubCommit", () => {
|
||||
const { openHandsGetMock } = vi.hoisted(() => ({
|
||||
openHandsGetMock: vi.fn(),
|
||||
const { githubGetMock } = vi.hoisted(() => ({
|
||||
githubGetMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/api/open-hands-axios", () => ({
|
||||
openHands: {
|
||||
get: openHandsGetMock,
|
||||
vi.mock("../../src/api/github-axios-instance", () => ({
|
||||
github: {
|
||||
get: githubGetMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("retrieveLatestGitHubCommit", () => {
|
||||
},
|
||||
};
|
||||
|
||||
openHandsGetMock.mockResolvedValueOnce({
|
||||
githubGetMock.mockResolvedValueOnce({
|
||||
data: [mockCommit],
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("retrieveLatestGitHubCommit", () => {
|
||||
it("should return null when repository is empty", async () => {
|
||||
const error = new Error("Repository is empty");
|
||||
(error as any).response = { status: 409 };
|
||||
openHandsGetMock.mockRejectedValueOnce(error);
|
||||
githubGetMock.mockRejectedValueOnce(error);
|
||||
|
||||
const result = await retrieveLatestGitHubCommit("user/empty-repo");
|
||||
expect(result).toBeNull();
|
||||
@@ -40,7 +40,7 @@ describe("retrieveLatestGitHubCommit", () => {
|
||||
it("should throw error for other error cases", async () => {
|
||||
const error = new Error("Network error");
|
||||
(error as any).response = { status: 500 };
|
||||
openHandsGetMock.mockRejectedValueOnce(error);
|
||||
githubGetMock.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(retrieveLatestGitHubCommit("user/repo")).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, test, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
|
||||
import { clickOnEditButton } from "./utils";
|
||||
|
||||
describe("ConversationCard", () => {
|
||||
const onClick = vi.fn();
|
||||
@@ -20,9 +19,9 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
|
||||
@@ -34,20 +33,20 @@ describe("ConversationCard", () => {
|
||||
within(card).getByText(expectedDate);
|
||||
});
|
||||
|
||||
it("should render the selectedRepository if available", () => {
|
||||
it("should render the repo if available", () => {
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-selected-repository"),
|
||||
screen.queryByTestId("conversation-card-repo"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
@@ -55,13 +54,13 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("conversation-card-selected-repository");
|
||||
screen.getByTestId("conversation-card-repo");
|
||||
});
|
||||
|
||||
it("should call onClick when the card is clicked", async () => {
|
||||
@@ -71,9 +70,9 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -90,9 +89,9 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -115,9 +114,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -132,23 +131,21 @@ describe("ConversationCard", () => {
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
|
||||
test("clicking the repo should not trigger the onClick handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedRepository = screen.getByTestId(
|
||||
"conversation-card-selected-repository",
|
||||
);
|
||||
await user.click(selectedRepository);
|
||||
const repo = screen.getByTestId("conversation-card-repo");
|
||||
await user.click(repo);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -159,22 +156,14 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
onChangeTitle={onChangeTitle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = screen.getByTestId("conversation-card-title");
|
||||
expect(title).toBeDisabled();
|
||||
|
||||
await clickOnEditButton(user);
|
||||
|
||||
expect(title).toBeEnabled();
|
||||
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
|
||||
// expect to be focused
|
||||
expect(document.activeElement).toBe(title);
|
||||
|
||||
await user.clear(title);
|
||||
await user.type(title, "New Conversation Name ");
|
||||
@@ -182,7 +171,6 @@ describe("ConversationCard", () => {
|
||||
|
||||
expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
|
||||
expect(title).toHaveValue("New Conversation Name");
|
||||
expect(title).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should reset title and not call onChangeTitle when the title is empty", async () => {
|
||||
@@ -192,14 +180,12 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
await clickOnEditButton(user);
|
||||
|
||||
const title = screen.getByTestId("conversation-card-title");
|
||||
|
||||
await user.clear(title);
|
||||
@@ -216,9 +202,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -235,9 +221,9 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -253,19 +239,19 @@ describe("ConversationCard", () => {
|
||||
});
|
||||
|
||||
describe("state indicator", () => {
|
||||
it("should render the 'STOPPED' indicator by default", () => {
|
||||
it("should render the 'cold' indicator by default", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("STOPPED-indicator");
|
||||
screen.getByTestId("cold-indicator");
|
||||
});
|
||||
|
||||
it("should render the other indicators when provided", () => {
|
||||
@@ -274,15 +260,15 @@ describe("ConversationCard", () => {
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
status="RUNNING"
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
state="warm"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("STOPPED-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("RUNNING-indicator");
|
||||
expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("warm-indicator");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import userEvent from "@testing-library/user-event";
|
||||
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";
|
||||
|
||||
describe("ConversationPanel", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
@@ -53,8 +52,6 @@ describe("ConversationPanel", () => {
|
||||
renderConversationPanel();
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// NOTE that we filter out conversations that don't have a created_at property
|
||||
// (mock data has 4 conversations, but only 3 have a created_at property)
|
||||
expect(cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -172,15 +169,13 @@ describe("ConversationPanel", () => {
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
const title = within(cards[0]).getByTestId("conversation-card-title");
|
||||
|
||||
await clickOnEditButton(user);
|
||||
|
||||
await user.clear(title);
|
||||
await user.type(title, "Conversation 1 Renamed");
|
||||
await user.tab();
|
||||
|
||||
// Ensure the conversation is renamed
|
||||
expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
|
||||
title: "Conversation 1 Renamed",
|
||||
name: "Conversation 1 Renamed",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,8 +196,6 @@ describe("ConversationPanel", () => {
|
||||
// Ensure the conversation is not renamed
|
||||
expect(updateUserConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
await clickOnEditButton(user);
|
||||
|
||||
await user.type(title, "Conversation 1");
|
||||
await user.click(title);
|
||||
await user.tab();
|
||||
@@ -224,4 +217,51 @@ describe("ConversationPanel", () => {
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("New Conversation Button", () => {
|
||||
it("should display a confirmation modal when clicking", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("confirm-new-conversation-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const newProjectButton = screen.getByTestId("new-conversation-button");
|
||||
await user.click(newProjectButton);
|
||||
|
||||
const modal = screen.getByTestId("confirm-new-conversation-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call endSession and close panel after confirming", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
const newProjectButton = screen.getByTestId("new-conversation-button");
|
||||
await user.click(newProjectButton);
|
||||
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(endSessionMock).toHaveBeenCalledOnce();
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should close the modal when cancelling", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
const newProjectButton = screen.getByTestId("new-conversation-button");
|
||||
await user.click(newProjectButton);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(endSessionMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByTestId("confirm-new-conversation-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { UserEvent } from "@testing-library/user-event";
|
||||
|
||||
export const clickOnEditButton = async (user: UserEvent) => {
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const editButton = within(menu).getByTestId("edit-button");
|
||||
|
||||
await user.click(editButton);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it } 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 { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
const renderSidebar = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -18,7 +18,7 @@ const renderSidebar = () => {
|
||||
};
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
"should have the conversation panel open by default",
|
||||
() => {
|
||||
renderSidebar();
|
||||
@@ -26,7 +26,7 @@ describe("Sidebar", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
"should toggle the conversation panel",
|
||||
async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { RuntimeSizeSelector } from "#/components/shared/modals/settings/runtime-size-selector";
|
||||
|
||||
const renderRuntimeSizeSelector = () =>
|
||||
renderWithProviders(<RuntimeSizeSelector isDisabled={false} />);
|
||||
|
||||
describe("RuntimeSizeSelector", () => {
|
||||
it("should show both runtime size options", () => {
|
||||
renderRuntimeSizeSelector();
|
||||
// The options are in the hidden select element
|
||||
const select = screen.getByRole("combobox", { hidden: true });
|
||||
expect(select).toHaveValue("1");
|
||||
expect(select).toHaveDisplayValue("1x (2 core, 8G)");
|
||||
expect(select.children).toHaveLength(3); // Empty option + 2 size options
|
||||
});
|
||||
|
||||
it("should show the full description text for disabled options", async () => {
|
||||
renderRuntimeSizeSelector();
|
||||
|
||||
// Click the button to open the dropdown
|
||||
const button = screen.getByRole("button", {
|
||||
name: "1x (2 core, 8G) SETTINGS_FORM$RUNTIME_SIZE_LABEL",
|
||||
});
|
||||
button.click();
|
||||
|
||||
// Wait for the dropdown to open and find the description text
|
||||
const description = await screen.findByText(
|
||||
"Runtime sizes over 1 are disabled by default, please contact contact@all-hands.dev to get access to larger runtimes.",
|
||||
);
|
||||
expect(description).toBeInTheDocument();
|
||||
expect(description).toHaveClass("whitespace-normal", "break-words");
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
describe("SettingsForm", () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "123",
|
||||
});
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => (
|
||||
<SettingsForm
|
||||
settings={DEFAULT_SETTINGS}
|
||||
models={[]}
|
||||
agents={[]}
|
||||
securityAnalyzers={[]}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
),
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
|
||||
it("should not show runtime size selector by default", () => {
|
||||
renderWithProviders(<RouterStub />);
|
||||
expect(screen.queryByText("Runtime Size")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show runtime size selector when advanced options are enabled", async () => {
|
||||
renderWithProviders(<RouterStub />);
|
||||
const advancedSwitch = screen.getByRole("switch", {
|
||||
name: "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
|
||||
});
|
||||
fireEvent.click(advancedSwitch);
|
||||
await screen.findByText("SETTINGS_FORM$RUNTIME_SIZE_LABEL");
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as ChatSlice from "#/state/chat-slice";
|
||||
import {
|
||||
updateStatusWhenErrorMessagePresent,
|
||||
} from "#/context/ws-client-provider";
|
||||
|
||||
describe("Propagate error message", () => {
|
||||
it("should do nothing when no message was passed from server", () => {
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage");
|
||||
updateStatusWhenErrorMessagePresent(null)
|
||||
updateStatusWhenErrorMessagePresent(undefined)
|
||||
updateStatusWhenErrorMessagePresent({})
|
||||
updateStatusWhenErrorMessagePresent({message: null})
|
||||
|
||||
expect(addErrorMessageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display error to user when present", () => {
|
||||
const message = "We have a problem!"
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage")
|
||||
updateStatusWhenErrorMessagePresent({message})
|
||||
|
||||
expect(addErrorMessageSpy).toHaveBeenCalledWith({
|
||||
message,
|
||||
status_update: true,
|
||||
type: 'error'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import App from "#/routes/_oh.app/route";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
describe("App", () => {
|
||||
const RouteStub = createRoutesStub([
|
||||
@@ -35,7 +35,7 @@ describe("App", () => {
|
||||
await screen.findByTestId("app-route");
|
||||
});
|
||||
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
"should call endSession if the user does not have permission to view conversation",
|
||||
async () => {
|
||||
const errorToastSpy = vi.spyOn(toast, "error");
|
||||
@@ -59,11 +59,10 @@ describe("App", () => {
|
||||
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "9999",
|
||||
last_updated_at: "",
|
||||
created_at: "",
|
||||
title: "",
|
||||
selected_repository: "",
|
||||
status: "STOPPED",
|
||||
lastUpdated: "",
|
||||
name: "",
|
||||
repo: "",
|
||||
state: "cold",
|
||||
});
|
||||
const { rerender } = renderWithProviders(
|
||||
<RouteStub initialEntries={["/conversation/9999"]} />,
|
||||
|
||||
1044
frontend/package-lock.json
generated
1044
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.19.0",
|
||||
"version": "0.18.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -8,32 +8,32 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@nextui-org/react": "^2.6.11",
|
||||
"@nextui-org/react": "^2.6.10",
|
||||
"@react-router/node": "^7.1.1",
|
||||
"@react-router/serve": "^7.1.1",
|
||||
"@react-types/shared": "^3.25.0",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@tanstack/react-query": "^5.63.0",
|
||||
"@tanstack/react-query": "^5.62.12",
|
||||
"@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",
|
||||
"i18next": "^24.2.1",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"isbot": "^5.1.20",
|
||||
"isbot": "^5.1.19",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.205.0",
|
||||
"posthog-js": "^1.203.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
@@ -78,13 +78,13 @@
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.16",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.9",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.3",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
|
||||
103
frontend/src/api/github-axios-instance.ts
Normal file
103
frontend/src/api/github-axios-instance.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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 = (
|
||||
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;
|
||||
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,
|
||||
};
|
||||
@@ -1,18 +1,14 @@
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { github } from "./github-axios-instance";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
|
||||
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
|
||||
data: T | GitHubErrorReponse | null,
|
||||
): data is GitHubErrorReponse =>
|
||||
!!data && "message" in data && data.message !== undefined;
|
||||
|
||||
/**
|
||||
* Given the user, retrieves app installations IDs for OpenHands Github App
|
||||
* Uses user access token for Github App
|
||||
*/
|
||||
export const retrieveGitHubAppInstallations = async (): Promise<number[]> => {
|
||||
const response = await openHands.get<GithubAppInstallation>(
|
||||
"/api/github/installations",
|
||||
const response = await github.get<GithubAppInstallation>(
|
||||
"/user/installations",
|
||||
);
|
||||
|
||||
return response.data.installations.map((installation) => installation.id);
|
||||
@@ -92,8 +88,20 @@ export const retrieveGitHubUserRepositories = async (
|
||||
* @returns The authenticated user or an error response
|
||||
*/
|
||||
export const retrieveGitHubUser = async () => {
|
||||
const response = await openHands.get<GitHubUser>("/api/github/user");
|
||||
return response.data;
|
||||
const response = await github.get<GitHubUser>("/user");
|
||||
|
||||
const { data } = response;
|
||||
|
||||
const user: GitHubUser = {
|
||||
id: data.id,
|
||||
login: data.login,
|
||||
avatar_url: data.avatar_url,
|
||||
company: data.company,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const searchPublicRepositories = async (
|
||||
@@ -102,11 +110,11 @@ export const searchPublicRepositories = async (
|
||||
sort: "" | "updated" | "stars" | "forks" = "stars",
|
||||
order: "desc" | "asc" = "desc",
|
||||
): Promise<GitHubRepository[]> => {
|
||||
const response = await openHands.get<{ items: GitHubRepository[] }>(
|
||||
"/api/github/search/repositories",
|
||||
const response = await github.get<{ items: GitHubRepository[] }>(
|
||||
"/search/repositories",
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
q: query,
|
||||
per_page,
|
||||
sort,
|
||||
order,
|
||||
@@ -120,9 +128,8 @@ export const retrieveLatestGitHubCommit = async (
|
||||
repository: string,
|
||||
): Promise<GitHubCommit | null> => {
|
||||
try {
|
||||
const [owner, repo] = repository.split("/");
|
||||
const response = await openHands.get<GitHubCommit[]>(
|
||||
`/api/github/repos/${owner}/${repo}/commits`,
|
||||
const response = await github.get<GitHubCommit[]>(
|
||||
`/repos/${repository}/commits`,
|
||||
{
|
||||
params: {
|
||||
per_page: 1,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
GetVSCodeUrlResponse,
|
||||
AuthenticateResponse,
|
||||
Conversation,
|
||||
ResultSet,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
@@ -223,10 +222,8 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/conversations?limit=9",
|
||||
);
|
||||
return data.results;
|
||||
const { data } = await openHands.get<Conversation[]>("/api/conversations");
|
||||
return data;
|
||||
}
|
||||
|
||||
static async deleteUserConversation(conversationId: string): Promise<void> {
|
||||
@@ -235,9 +232,9 @@ class OpenHands {
|
||||
|
||||
static async updateUserConversation(
|
||||
conversationId: string,
|
||||
conversation: Partial<Omit<Conversation, "conversation_id">>,
|
||||
conversation: Partial<Omit<Conversation, "id">>,
|
||||
): Promise<void> {
|
||||
await openHands.patch(`/api/conversations/${conversationId}`, conversation);
|
||||
await openHands.put(`/api/conversations/${conversationId}`, conversation);
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectStatus } from "#/components/features/conversation-panel/conversation-state-indicator";
|
||||
import { ProjectState } from "#/components/features/conversation-panel/conversation-state-indicator";
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
@@ -62,14 +62,8 @@ export interface AuthenticateResponse {
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string;
|
||||
title: string;
|
||||
selected_repository: string | null;
|
||||
last_updated_at: string;
|
||||
created_at: string;
|
||||
status: ProjectStatus;
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
results: T[];
|
||||
next_page_id: string | null;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string;
|
||||
state: ProjectState;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ContextMenu({
|
||||
<ul
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
className={cn("bg-[#404040] rounded-md w-[140px]", className)}
|
||||
className={cn("bg-[#404040] rounded-md w-[224px]", className)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
|
||||
@@ -25,14 +25,10 @@ export function ConfirmDeleteModal({
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
onClick={onConfirm}
|
||||
className="bg-danger font-bold"
|
||||
className="bg-[#4465DB]"
|
||||
text="Confirm"
|
||||
/>
|
||||
<ModalButton
|
||||
onClick={onCancel}
|
||||
className="bg-neutral-500 font-bold"
|
||||
text="Cancel"
|
||||
/>
|
||||
<ModalButton onClick={onCancel} className="bg-danger" text="Cancel" />
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
|
||||
interface ConversationCardContextMenuProps {
|
||||
onClose: () => void;
|
||||
onDelete: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onEdit: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function ConversationCardContextMenu({
|
||||
onClose,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: ConversationCardContextMenuProps) {
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
testId="context-menu"
|
||||
className="left-full float-right"
|
||||
>
|
||||
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
|
||||
Edit Title
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -2,33 +2,33 @@ import React from "react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import {
|
||||
ProjectStatus,
|
||||
ProjectState,
|
||||
ConversationStateIndicator,
|
||||
} from "./conversation-state-indicator";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { EllipsisButton } from "./ellipsis-button";
|
||||
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
|
||||
interface ConversationCardProps {
|
||||
interface ProjectCardProps {
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
onChangeTitle: (title: string) => void;
|
||||
title: string;
|
||||
selectedRepository: string | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
status?: ProjectStatus;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string; // ISO 8601
|
||||
state?: ProjectState;
|
||||
}
|
||||
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
onChangeTitle,
|
||||
title,
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
status = "STOPPED",
|
||||
}: ConversationCardProps) {
|
||||
name,
|
||||
repo,
|
||||
lastUpdated,
|
||||
state = "cold",
|
||||
}: ProjectCardProps) {
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleBlur = () => {
|
||||
@@ -38,15 +38,7 @@ export function ConversationCard({
|
||||
inputRef.current!.value = trimmed;
|
||||
} else {
|
||||
// reset the value if it's empty
|
||||
inputRef.current!.value = title;
|
||||
}
|
||||
|
||||
setTitleMode("view");
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
inputRef.current!.value = name;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,62 +51,51 @@ export function ConversationCard({
|
||||
onDelete();
|
||||
};
|
||||
|
||||
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setTitleMode("edit");
|
||||
setContextMenuVisible(false);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (titleMode === "edit") {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [titleMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
onClick={onClick}
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer"
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
data-testid="conversation-card-title"
|
||||
ref={inputRef}
|
||||
disabled={titleMode === "view"}
|
||||
data-testid="conversation-card-title"
|
||||
onClick={handleInputClick}
|
||||
onBlur={handleBlur}
|
||||
onKeyUp={handleKeyUp}
|
||||
type="text"
|
||||
defaultValue={title}
|
||||
className="text-sm leading-6 font-semibold bg-transparent w-full"
|
||||
defaultValue={name}
|
||||
className="text-sm leading-6 font-semibold bg-transparent"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<ConversationStateIndicator status={status} />
|
||||
<ConversationStateIndicator state={state} />
|
||||
<EllipsisButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
{contextMenuVisible && (
|
||||
<ContextMenu testId="context-menu" className="absolute left-full">
|
||||
<ContextMenuListItem
|
||||
testId="delete-button"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{contextMenuVisible && (
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
)}
|
||||
{selectedRepository && (
|
||||
{repo && (
|
||||
<ConversationRepoLink
|
||||
selectedRepository={selectedRepository}
|
||||
repo={repo}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
|
||||
<time>{formatTimeDelta(new Date(lastUpdated))} ago</time>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
interface ConversationPanelWrapperProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function ConversationPanelWrapper({
|
||||
isOpen,
|
||||
children,
|
||||
}: React.PropsWithChildren<ConversationPanelWrapperProps>) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const portalTarget = document.getElementById("root-outlet");
|
||||
if (!portalTarget) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="absolute h-full w-full left-0 top-0 z-20 bg-black/80 rounded-xl">
|
||||
{children}
|
||||
</div>,
|
||||
portalTarget,
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useLocation, useNavigate, useParams } from "react-router";
|
||||
import { ConversationCard } from "./conversation-card";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
import { NewConversationButton } from "./new-conversation-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { ExitConversationModal } from "./exit-conversation-modal";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
|
||||
interface ConversationPanelProps {
|
||||
onClose: () => void;
|
||||
@@ -17,8 +17,9 @@ interface ConversationPanelProps {
|
||||
export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const { conversationId: cid } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const endSession = useEndSession();
|
||||
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
|
||||
|
||||
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
|
||||
React.useState(false);
|
||||
@@ -59,7 +60,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
if (oldTitle !== newTitle)
|
||||
updateConversation({
|
||||
id: conversationId,
|
||||
conversation: { title: newTitle },
|
||||
conversation: { name: newTitle },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -70,11 +71,15 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="conversation-panel"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl overflow-y-auto"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl"
|
||||
>
|
||||
<div className="pt-4 px-4 flex items-center justify-between">
|
||||
{location.pathname.startsWith("/conversation") && (
|
||||
<NewConversationButton
|
||||
onClick={() => setConfirmExitConversationModalVisible(true)}
|
||||
/>
|
||||
)}
|
||||
{isFetching && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
{error && (
|
||||
@@ -93,12 +98,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
onClick={() => handleClickCard(project.conversation_id)}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.title, title)
|
||||
handleChangeTitle(project.conversation_id, project.name, title)
|
||||
}
|
||||
title={project.title}
|
||||
selectedRepository={project.selected_repository}
|
||||
lastUpdatedAt={project.last_updated_at}
|
||||
status={project.status}
|
||||
name={project.name}
|
||||
repo={project.repo}
|
||||
lastUpdated={project.lastUpdated}
|
||||
state={project.state}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
interface ConversationRepoLinkProps {
|
||||
selectedRepository: string;
|
||||
repo: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
export function ConversationRepoLink({
|
||||
selectedRepository,
|
||||
repo,
|
||||
onClick,
|
||||
}: ConversationRepoLinkProps) {
|
||||
return (
|
||||
<a
|
||||
data-testid="conversation-card-selected-repository"
|
||||
href={`https://github.com/${selectedRepository}`}
|
||||
data-testid="conversation-card-repo"
|
||||
href={`https://github.com/${repo}`}
|
||||
target="_blank noopener noreferrer"
|
||||
onClick={onClick}
|
||||
className="text-xs text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{selectedRepository}
|
||||
{repo}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
import ColdIcon from "./state-indicators/cold.svg?react";
|
||||
import CoolingIcon from "./state-indicators/cooling.svg?react";
|
||||
import FinishedIcon from "./state-indicators/finished.svg?react";
|
||||
import RunningIcon from "./state-indicators/running.svg?react";
|
||||
import WaitingIcon from "./state-indicators/waiting.svg?react";
|
||||
import WarmIcon from "./state-indicators/warm.svg?react";
|
||||
|
||||
type SVGIcon = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
export type ProjectStatus = "RUNNING" | "STOPPED";
|
||||
export type ProjectState =
|
||||
| "cold"
|
||||
| "cooling"
|
||||
| "finished"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "warm";
|
||||
|
||||
const INDICATORS: Record<ProjectStatus, SVGIcon> = {
|
||||
STOPPED: ColdIcon,
|
||||
RUNNING: RunningIcon,
|
||||
const INDICATORS: Record<ProjectState, SVGIcon> = {
|
||||
cold: ColdIcon,
|
||||
cooling: CoolingIcon,
|
||||
finished: FinishedIcon,
|
||||
running: RunningIcon,
|
||||
waiting: WaitingIcon,
|
||||
warm: WarmIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
status: ProjectStatus;
|
||||
state: ProjectState;
|
||||
}
|
||||
|
||||
export function ConversationStateIndicator({
|
||||
status,
|
||||
state,
|
||||
}: ConversationStateIndicatorProps) {
|
||||
const StateIcon = INDICATORS[status];
|
||||
const StateIcon = INDICATORS[state];
|
||||
|
||||
return (
|
||||
<div data-testid={`${status}-indicator`}>
|
||||
<div data-testid={`${state}-indicator`}>
|
||||
<StateIcon />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ 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";
|
||||
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";
|
||||
|
||||
@@ -16,24 +16,16 @@ export function ProjectMenuDetails({
|
||||
}: ProjectMenuDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
href={`https://github.com/${repoName}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center gap-2 min-w-0"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{avatar && (
|
||||
<img
|
||||
src={avatar}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded-full flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm leading-6 font-semibold truncate flex-1">
|
||||
{repoName}
|
||||
</span>
|
||||
<ExternalLinkIcon width={16} height={16} className="flex-shrink-0" />
|
||||
{avatar && <img src={avatar} alt="" className="w-4 h-4 rounded-full" />}
|
||||
<span className="text-sm leading-6 font-semibold">{repoName}</span>
|
||||
<ExternalLinkIcon width={16} height={16} />
|
||||
</a>
|
||||
<a
|
||||
href={lastCommit.html_url}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { FaListUl } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useLocation } from "react-router";
|
||||
import FolderIcon from "#/icons/docs.svg?react";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
@@ -11,20 +11,16 @@ import { ExitProjectButton } from "#/components/shared/buttons/exit-project-butt
|
||||
import { SettingsButton } from "#/components/shared/buttons/settings-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { ExitProjectConfirmationModal } from "#/components/shared/modals/exit-project-confirmation-modal";
|
||||
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
export function Sidebar() {
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
const location = useLocation();
|
||||
const user = useGitHubUser();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { logout } = useAuth();
|
||||
@@ -34,9 +30,11 @@ export function Sidebar() {
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] =
|
||||
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
|
||||
React.useState(false);
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(
|
||||
MULTI_CONVO_UI_IS_ENABLED,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the github token is invalid, open the account settings modal again
|
||||
@@ -45,11 +43,6 @@ export function Sidebar() {
|
||||
}
|
||||
}, [user.isError]);
|
||||
|
||||
const handleEndSession = () => {
|
||||
dispatch(setCurrentAgentState(AgentState.LOADING));
|
||||
endSession();
|
||||
};
|
||||
|
||||
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
|
||||
@@ -58,30 +51,22 @@ export function Sidebar() {
|
||||
setAccountSettingsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleClickLogo = () => {
|
||||
if (location.pathname.startsWith("/conversations/"))
|
||||
setStartNewProjectModalIsOpen(true);
|
||||
};
|
||||
|
||||
const showSettingsModal =
|
||||
isAuthed && (!settingsAreUpToDate || settingsModalIsOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1 relative">
|
||||
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
|
||||
<div className="w-[34px] h-[34px] flex items-center justify-center mb-7">
|
||||
<AllHandsLogoButton onClick={handleEndSession} />
|
||||
<div className="w-[34px] h-[34px] flex items-center justify-center">
|
||||
<AllHandsLogoButton onClick={handleClickLogo} />
|
||||
</div>
|
||||
{user.isLoading && <LoadingSpinner size="small" />}
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
{MULTI_CONVERSATION_UI && (
|
||||
<TooltipButton
|
||||
data-testid="toggle-conversation-panel"
|
||||
tooltip="Conversations"
|
||||
ariaLabel="Conversations"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
>
|
||||
<FaListUl size={22} />
|
||||
</TooltipButton>
|
||||
)}
|
||||
<DocsButton />
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
{!user.isLoading && (
|
||||
<UserActions
|
||||
user={
|
||||
@@ -91,14 +76,33 @@ export function Sidebar() {
|
||||
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
{MULTI_CONVO_UI_IS_ENABLED && (
|
||||
<button
|
||||
data-testid="toggle-conversation-panel"
|
||||
type="button"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
conversationPanelIsOpen ? "border-b-2 border-[#FFE165]" : "",
|
||||
)}
|
||||
>
|
||||
<FolderIcon width={28} height={28} />
|
||||
</button>
|
||||
)}
|
||||
<DocsButton />
|
||||
<ExitProjectButton
|
||||
onClick={() => setStartNewProjectModalIsOpen(true)}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{conversationPanelIsOpen && (
|
||||
<ConversationPanelWrapper isOpen={conversationPanelIsOpen}>
|
||||
<div
|
||||
className="absolute h-full left-[calc(100%+12px)] top-0 z-20" // 12px padding (sidebar parent)
|
||||
>
|
||||
<ConversationPanel
|
||||
onClose={() => setConversationPanelIsOpen(false)}
|
||||
/>
|
||||
</ConversationPanelWrapper>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -112,6 +116,11 @@ export function Sidebar() {
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
))}
|
||||
{startNewProjectModalIsOpen && (
|
||||
<ExitProjectConfirmationModal
|
||||
onClose={() => setStartNewProjectModalIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import DefaultUserAvatar from "#/icons/default-user.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Avatar } from "./avatar";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface UserAvatarProps {
|
||||
onClick: () => void;
|
||||
@@ -11,11 +11,10 @@ interface UserAvatarProps {
|
||||
}
|
||||
|
||||
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
return (
|
||||
<TooltipButton
|
||||
testId="user-avatar"
|
||||
tooltip="Account settings"
|
||||
ariaLabel="Account settings"
|
||||
const buttonContent = (
|
||||
<button
|
||||
data-testid="user-avatar"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2 border-gray-200",
|
||||
@@ -31,6 +30,12 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
</TooltipButton>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content="Account settings" closeDelay={100}>
|
||||
{buttonContent}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
|
||||
ariaLabel="All Hands Logo"
|
||||
onClick={onClick}
|
||||
>
|
||||
<AllHandsLogo width={44} height={30} />
|
||||
<AllHandsLogo width={34} height={23} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
|
||||
onClick={onClick}
|
||||
testId="new-project-button"
|
||||
>
|
||||
<NewProjectIcon width={26} height={26} />
|
||||
<NewProjectIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FaCog } from "react-icons/fa";
|
||||
import CogTooth from "#/assets/cog-tooth";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
interface SettingsButtonProps {
|
||||
@@ -13,7 +13,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
ariaLabel="Settings"
|
||||
onClick={onClick}
|
||||
>
|
||||
<FaCog size={24} />
|
||||
<CogTooth />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface TooltipButtonProps {
|
||||
children: ReactNode;
|
||||
@@ -9,7 +8,6 @@ interface TooltipButtonProps {
|
||||
href?: string;
|
||||
ariaLabel: string;
|
||||
testId?: string;
|
||||
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
|
||||
}
|
||||
|
||||
export function TooltipButton({
|
||||
@@ -19,7 +17,6 @@ export function TooltipButton({
|
||||
href,
|
||||
ariaLabel,
|
||||
testId,
|
||||
className,
|
||||
}: TooltipButtonProps) {
|
||||
const buttonContent = (
|
||||
<button
|
||||
@@ -27,7 +24,7 @@ export function TooltipButton({
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
className={cn("hover:opacity-80", className)}
|
||||
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -38,7 +35,7 @@ export function TooltipButton({
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className={cn("hover:opacity-80", className)}
|
||||
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
@@ -48,7 +45,7 @@ export function TooltipButton({
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} closeDelay={100} placement="right">
|
||||
<Tooltip content={tooltip} closeDelay={100}>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AdvancedOptionSwitch({
|
||||
<Switch
|
||||
isDisabled={isDisabled}
|
||||
name="use-advanced-options"
|
||||
defaultSelected={showAdvancedOptions}
|
||||
isSelected={showAdvancedOptions}
|
||||
onValueChange={setShowAdvancedOptions}
|
||||
classNames={{
|
||||
thumb: cn(
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectItem } from "@nextui-org/react";
|
||||
|
||||
interface RuntimeSizeSelectorProps {
|
||||
isDisabled: boolean;
|
||||
defaultValue?: number;
|
||||
}
|
||||
|
||||
export function RuntimeSizeSelector({
|
||||
isDisabled,
|
||||
defaultValue,
|
||||
}: RuntimeSizeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="runtime-size"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
{t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
|
||||
</label>
|
||||
<Select
|
||||
id="runtime-size"
|
||||
name="runtime-size"
|
||||
defaultSelectedKeys={[String(defaultValue || 1)]}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
|
||||
classNames={{
|
||||
trigger: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
|
||||
}}
|
||||
>
|
||||
<SelectItem key="1" value={1}>
|
||||
1x (2 core, 8G)
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
key="2"
|
||||
value={2}
|
||||
isDisabled
|
||||
classNames={{
|
||||
description:
|
||||
"whitespace-normal break-words min-w-[300px] max-w-[300px]",
|
||||
base: "min-w-[300px] max-w-[300px]",
|
||||
}}
|
||||
description="Runtime sizes over 1 are disabled by default, please contact contact@all-hands.dev to get access to larger runtimes."
|
||||
>
|
||||
2x (4 core, 16G)
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -21,9 +21,6 @@ import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
import { RuntimeSizeSelector } from "./runtime-size-selector";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
settings: Settings;
|
||||
@@ -43,7 +40,6 @@ export function SettingsForm({
|
||||
}: SettingsFormProps) {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const endSession = useEndSession();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
@@ -101,8 +97,6 @@ export function SettingsForm({
|
||||
posthog.capture("settings_saved", {
|
||||
LLM_MODEL: newSettings.LLM_MODEL,
|
||||
LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -128,8 +122,6 @@ export function SettingsForm({
|
||||
}
|
||||
};
|
||||
|
||||
const isSaasMode = config?.APP_MODE === "saas";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
@@ -172,21 +164,16 @@ export function SettingsForm({
|
||||
isSet={settings.LLM_API_KEY === "SET"}
|
||||
/>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
<AgentInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.AGENT}
|
||||
agents={agents}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAdvancedOptions && (
|
||||
<>
|
||||
<AgentInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.AGENT}
|
||||
agents={agents}
|
||||
/>
|
||||
|
||||
{isSaasMode && (
|
||||
<RuntimeSizeSelector
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.REMOTE_RUNTIME_RESOURCE_FACTOR}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SecurityAnalyzerInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.SECURITY_ANALYZER}
|
||||
|
||||
@@ -2,9 +2,14 @@ import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
removeGitHubTokenHeader,
|
||||
setGitHubTokenHeader,
|
||||
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 {
|
||||
gitHubToken: string | null;
|
||||
@@ -32,7 +37,8 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
localStorage.removeItem("ghToken");
|
||||
localStorage.removeItem("userId");
|
||||
|
||||
removeGitHubTokenHeader();
|
||||
removeOpenHandsGitHubTokenHeader();
|
||||
removeGitHubAuthTokenHeader();
|
||||
};
|
||||
|
||||
const setGitHubToken = (token: string | null) => {
|
||||
@@ -40,7 +46,8 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("ghToken", token);
|
||||
setGitHubTokenHeader(token);
|
||||
setOpenHandsGitHubTokenHeader(token);
|
||||
setGitHubAuthTokenHeader(token);
|
||||
} else {
|
||||
clearGitHubToken();
|
||||
}
|
||||
@@ -80,6 +87,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
|
||||
setGitHubToken(storedGitHubToken);
|
||||
setUserId(userId);
|
||||
setupGithubAxiosInterceptors(refreshToken, logout);
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
|
||||
@@ -2,18 +2,12 @@ import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import {
|
||||
handleAssistantMessage,
|
||||
handleStatusMessage,
|
||||
} from "#/services/actions";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import { useRate } from "#/hooks/use-rate";
|
||||
import { OpenHandsParsedEvent } from "#/types/core";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { AgentStateChangeObservation } from "#/types/core/observations";
|
||||
|
||||
const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
const isOpenHandsMessage = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"id" in event &&
|
||||
@@ -21,26 +15,10 @@ const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
"message" in event &&
|
||||
"timestamp" in event;
|
||||
|
||||
const isUserMessage = (
|
||||
const isAgentStateChangeObservation = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is UserMessageAction =>
|
||||
"source" in event &&
|
||||
"type" in event &&
|
||||
event.source === "user" &&
|
||||
event.type === "message";
|
||||
|
||||
const isAssistantMessage = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is AssistantMessageAction =>
|
||||
"source" in event &&
|
||||
"type" in event &&
|
||||
event.source === "agent" &&
|
||||
event.type === "message";
|
||||
|
||||
const isMessageAction = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is UserMessageAction | AssistantMessageAction =>
|
||||
isUserMessage(event) || isAssistantMessage(event);
|
||||
): event is AgentStateChangeObservation =>
|
||||
"observation" in event && event.observation === "agent_state_changed";
|
||||
|
||||
export enum WsClientProviderStatus {
|
||||
CONNECTED,
|
||||
@@ -65,28 +43,16 @@ const WsClientContext = React.createContext<UseWsClient>({
|
||||
|
||||
interface WsClientProviderProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function updateStatusWhenErrorMessagePresent(data: unknown) {
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"message" in data &&
|
||||
typeof data.message === "string"
|
||||
) {
|
||||
handleStatusMessage({
|
||||
type: "error",
|
||||
message: data.message,
|
||||
status_update: true,
|
||||
});
|
||||
}
|
||||
ghToken: string | null;
|
||||
}
|
||||
|
||||
export function WsClientProvider({
|
||||
ghToken,
|
||||
conversationId,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const ghTokenRef = React.useRef<string | null>(ghToken);
|
||||
const [status, setStatus] = React.useState(
|
||||
WsClientProviderStatus.DISCONNECTED,
|
||||
);
|
||||
@@ -108,7 +74,7 @@ export function WsClientProvider({
|
||||
}
|
||||
|
||||
function handleMessage(event: Record<string, unknown>) {
|
||||
if (isOpenHandsEvent(event) && isMessageAction(event)) {
|
||||
if (isOpenHandsMessage(event) && !isAgentStateChangeObservation(event)) {
|
||||
messageRateHandler.record(new Date().getTime());
|
||||
}
|
||||
setEvents((prevEvents) => [...prevEvents, event]);
|
||||
@@ -119,7 +85,7 @@ export function WsClientProvider({
|
||||
handleAssistantMessage(event);
|
||||
}
|
||||
|
||||
function handleDisconnect(data: unknown) {
|
||||
function handleDisconnect() {
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
const sio = sioRef.current;
|
||||
if (!sio) {
|
||||
@@ -127,19 +93,13 @@ export function WsClientProvider({
|
||||
}
|
||||
sio.io.opts.query = sio.io.opts.query || {};
|
||||
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
}
|
||||
|
||||
function handleError(data: unknown) {
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
function handleError() {
|
||||
posthog.capture("socket_error");
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
lastEventRef.current = null;
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
@@ -158,6 +118,9 @@ export function WsClientProvider({
|
||||
|
||||
sio = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
auth: {
|
||||
github_token: ghToken || undefined,
|
||||
},
|
||||
query,
|
||||
});
|
||||
sio.on("connect", handleConnect);
|
||||
@@ -167,6 +130,7 @@ export function WsClientProvider({
|
||||
sio.on("disconnect", handleDisconnect);
|
||||
|
||||
sioRef.current = sio;
|
||||
ghTokenRef.current = ghToken;
|
||||
|
||||
return () => {
|
||||
sio.off("connect", handleConnect);
|
||||
@@ -175,7 +139,7 @@ export function WsClientProvider({
|
||||
sio.off("connect_failed", handleError);
|
||||
sio.off("disconnect", handleDisconnect);
|
||||
};
|
||||
}, [conversationId]);
|
||||
}, [ghToken, conversationId]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
|
||||
@@ -13,18 +13,13 @@ export const useCreateConversation = () => {
|
||||
const { gitHubToken } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedRepository, files, importedProjectZip } = useSelector(
|
||||
const { selectedRepository, files } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: { q?: string }) => {
|
||||
if (
|
||||
!variables.q?.trim() &&
|
||||
!selectedRepository &&
|
||||
files.length === 0 &&
|
||||
!importedProjectZip
|
||||
) {
|
||||
if (!variables.q?.trim() && !selectedRepository && files.length === 0) {
|
||||
throw new Error("No query provided");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type UploadFilesArgs = {
|
||||
files: File[];
|
||||
};
|
||||
|
||||
export const useUploadFiles = () => {
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ files }: UploadFilesArgs) =>
|
||||
OpenHands.uploadFiles(conversationId, files),
|
||||
});
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
export const useUserConversation = (cid: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["user", "conversation", cid],
|
||||
queryFn: () => OpenHands.getConversation(cid!),
|
||||
enabled: MULTI_CONVERSATION_UI && !!cid,
|
||||
enabled: MULTI_CONVO_UI_IS_ENABLED && !!cid,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -18,8 +18,6 @@ const getSettingsQueryFn = async () => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import React from "react";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import {
|
||||
getCurrentSettingsVersion,
|
||||
DEFAULT_SETTINGS,
|
||||
getCurrentSettingsVersion,
|
||||
getLocalStorageSettings,
|
||||
} from "#/services/settings";
|
||||
import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
|
||||
@@ -426,20 +426,6 @@
|
||||
"fr": "Réinitialiser aux valeurs par défaut",
|
||||
"tr": "Varsayılanlara Sıfırla"
|
||||
},
|
||||
"SETTINGS_FORM$RUNTIME_SIZE_LABEL": {
|
||||
"en": "Runtime Settings",
|
||||
"zh-CN": "运行时设置",
|
||||
"de": "Laufzeiteinstellungen",
|
||||
"ko-KR": "런타임 설정",
|
||||
"no": "Kjøretidsinnstillinger",
|
||||
"zh-TW": "運行時設定",
|
||||
"it": "Impostazioni Runtime",
|
||||
"pt": "Configurações de Runtime",
|
||||
"es": "Configuración de Runtime",
|
||||
"ar": "إعدادات وقت التشغيل",
|
||||
"fr": "Paramètres d'exécution",
|
||||
"tr": "Çalışma Zamanı Ayarları"
|
||||
},
|
||||
"CONFIGURATION$SETTINGS_NEED_UPDATE_MESSAGE": {
|
||||
"en": "We've changed some settings in the latest update. Take a minute to review.",
|
||||
"de": "Mit dem letzten Update haben wir ein paar Einstellungen geändert. Bitte kontrollieren Ihre Einstellungen.",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import {
|
||||
GetConfigResponse,
|
||||
Conversation,
|
||||
ResultSet,
|
||||
} from "#/api/open-hands.types";
|
||||
import { GetConfigResponse, Conversation } from "#/api/open-hands.types";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
const userPreferences = {
|
||||
@@ -21,33 +17,26 @@ const userPreferences = {
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
title: "My New Project",
|
||||
selected_repository: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
name: "My New Project",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "running",
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
title: "Repo Testing",
|
||||
selected_repository: "octocat/hello-world",
|
||||
name: "Repo Testing",
|
||||
repo: "octocat/hello-world",
|
||||
// 2 days ago
|
||||
last_updated_at: new Date(
|
||||
Date.now() - 2 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "STOPPED",
|
||||
lastUpdated: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "cold",
|
||||
},
|
||||
{
|
||||
conversation_id: "3",
|
||||
title: "Another Project",
|
||||
selected_repository: "octocat/earth",
|
||||
name: "Another Project",
|
||||
repo: "octocat/earth",
|
||||
// 5 days ago
|
||||
last_updated_at: new Date(
|
||||
Date.now() - 5 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "STOPPED",
|
||||
lastUpdated: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "finished",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -193,15 +182,9 @@ export const handlers = [
|
||||
|
||||
http.get("/api/options/config", () => HttpResponse.json({ APP_MODE: "oss" })),
|
||||
|
||||
http.get("/api/conversations", async () => {
|
||||
const values = Array.from(CONVERSATIONS.values());
|
||||
const results: ResultSet<Conversation> = {
|
||||
results: values,
|
||||
next_page_id: null,
|
||||
};
|
||||
|
||||
return HttpResponse.json(results, { status: 200 });
|
||||
}),
|
||||
http.get("/api/conversations", async () =>
|
||||
HttpResponse.json(Array.from(CONVERSATIONS.values())),
|
||||
),
|
||||
|
||||
http.delete("/api/conversations/:conversationId", async ({ params }) => {
|
||||
const { conversationId } = params;
|
||||
@@ -214,7 +197,7 @@ export const handlers = [
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}),
|
||||
|
||||
http.patch(
|
||||
http.put(
|
||||
"/api/conversations/:conversationId",
|
||||
async ({ params, request }) => {
|
||||
const { conversationId } = params;
|
||||
@@ -224,10 +207,10 @@ export const handlers = [
|
||||
|
||||
if (conversation) {
|
||||
const body = await request.json();
|
||||
if (typeof body === "object" && body?.title) {
|
||||
if (typeof body === "object" && body?.name) {
|
||||
CONVERSATIONS.set(conversationId, {
|
||||
...conversation,
|
||||
title: body.title,
|
||||
name: body.name,
|
||||
});
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
@@ -241,11 +224,10 @@ export const handlers = [
|
||||
http.post("/api/conversations", () => {
|
||||
const conversation: Conversation = {
|
||||
conversation_id: (Math.random() * 100).toString(),
|
||||
title: "New Conversation",
|
||||
selected_repository: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
name: "New Conversation",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "warm",
|
||||
};
|
||||
|
||||
CONVERSATIONS.set(conversation.conversation_id, conversation);
|
||||
|
||||
@@ -29,7 +29,10 @@ function Home() {
|
||||
const latestConversation = localStorage.getItem("latest_conversation_id");
|
||||
|
||||
return (
|
||||
<div className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2">
|
||||
<div
|
||||
data-testid="root-index"
|
||||
className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
|
||||
>
|
||||
<HeroHeading />
|
||||
<div className="flex flex-col gap-8 w-full md:w-[600px] items-center">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
|
||||
@@ -1,64 +1,31 @@
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { getGitHubTokenCommand } from "#/services/terminal-service";
|
||||
import { setImportedProjectZip } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { base64ToBlob } from "#/utils/base64-to-blob";
|
||||
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
|
||||
import { useGitHubUser } from "../../../hooks/query/use-github-user";
|
||||
import { isGitHubErrorReponse } from "#/api/github";
|
||||
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
|
||||
export const useHandleRuntimeActive = () => {
|
||||
const { gitHubToken } = useAuth();
|
||||
const { send } = useWsClient();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: user } = useGitHubUser();
|
||||
const { mutate: uploadFiles } = useUploadFiles();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
const { data: user } = useGitHubUser();
|
||||
|
||||
const { importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const userId = React.useMemo(() => {
|
||||
if (user && !isGitHubErrorReponse(user)) return user.id;
|
||||
return null;
|
||||
}, [user]);
|
||||
|
||||
const handleUploadFiles = (zip: string) => {
|
||||
const blob = base64ToBlob(zip);
|
||||
const file = new File([blob], "imported-project.zip", {
|
||||
type: blob.type,
|
||||
});
|
||||
uploadFiles(
|
||||
{ files: [file] },
|
||||
{
|
||||
onError: () => {
|
||||
toast.error("Failed to upload project files.");
|
||||
},
|
||||
},
|
||||
);
|
||||
dispatch(setImportedProjectZip(null));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (runtimeActive && userId && gitHubToken) {
|
||||
// Export if the user valid, this could happen mid-session so it is handled here
|
||||
send(getGitHubTokenCommand(gitHubToken));
|
||||
}
|
||||
}, [userId, gitHubToken, runtimeActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (runtimeActive && importedProjectZip) {
|
||||
handleUploadFiles(importedProjectZip);
|
||||
}
|
||||
}, [runtimeActive, importedProjectZip]);
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useUserConversation } from "#/hooks/query/get-conversation-permissions"
|
||||
import { CountBadge } from "#/components/layout/count-badge";
|
||||
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 { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
function AppContent() {
|
||||
const { gitHubToken } = useAuth();
|
||||
@@ -73,7 +73,7 @@ function AppContent() {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (MULTI_CONVERSATION_UI && isFetched && !conversation) {
|
||||
if (MULTI_CONVO_UI_IS_ENABLED && isFetched && !conversation) {
|
||||
toast.error(
|
||||
"This conversation does not exist, or you do not have permission to access it.",
|
||||
);
|
||||
@@ -175,7 +175,7 @@ function AppContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<WsClientProvider conversationId={conversationId}>
|
||||
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
|
||||
<EventHandler>
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
|
||||
@@ -78,10 +78,7 @@ export default function MainApp() {
|
||||
>
|
||||
<Sidebar />
|
||||
|
||||
<div
|
||||
id="root-outlet"
|
||||
className="h-[calc(100%-50px)] md:h-full w-full relative"
|
||||
>
|
||||
<div className="h-[calc(100%-50px)] md:h-full w-full relative">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
if (message.args && message.args.thought) {
|
||||
store.dispatch(addAssistantMessage(message.args.thought));
|
||||
}
|
||||
// Need to convert ActionMessage to RejectAction
|
||||
// @ts-expect-error TODO: fix
|
||||
store.dispatch(addAssistantAction(message));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export type Settings = {
|
||||
LLM_API_KEY: string | null;
|
||||
CONFIRMATION_MODE: boolean;
|
||||
SECURITY_ANALYZER: string;
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
@@ -19,7 +18,6 @@ export type ApiSettings = {
|
||||
llm_api_key: string | null;
|
||||
confirmation_mode: boolean;
|
||||
security_analyzer: string;
|
||||
remote_runtime_resource_factor: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -30,7 +28,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
LLM_API_KEY: null,
|
||||
CONFIRMATION_MODE: false,
|
||||
SECURITY_ANALYZER: "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
|
||||
};
|
||||
|
||||
export const getCurrentSettingsVersion = () => {
|
||||
@@ -69,8 +66,6 @@ export const getLocalStorageSettings = (): Settings => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -78,8 +73,3 @@ export const getLocalStorageSettings = (): Settings => {
|
||||
* 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();
|
||||
|
||||
@@ -73,7 +73,7 @@ export const chatSlice = createSlice({
|
||||
state.messages.push(message);
|
||||
},
|
||||
|
||||
addAssistantMessage(state: SliceState, action: PayloadAction<string>) {
|
||||
addAssistantMessage(state, action: PayloadAction<string>) {
|
||||
const message: Message = {
|
||||
type: "thought",
|
||||
sender: "assistant",
|
||||
@@ -85,10 +85,7 @@ export const chatSlice = createSlice({
|
||||
state.messages.push(message);
|
||||
},
|
||||
|
||||
addAssistantAction(
|
||||
state: SliceState,
|
||||
action: PayloadAction<OpenHandsAction>,
|
||||
) {
|
||||
addAssistantAction(state, action: PayloadAction<OpenHandsAction>) {
|
||||
const actionID = action.payload.action;
|
||||
if (!HANDLED_ACTIONS.includes(actionID)) {
|
||||
return;
|
||||
@@ -128,7 +125,7 @@ export const chatSlice = createSlice({
|
||||
},
|
||||
|
||||
addAssistantObservation(
|
||||
state: SliceState,
|
||||
state,
|
||||
observation: PayloadAction<OpenHandsObservation>,
|
||||
) {
|
||||
const observationID = observation.payload.observation;
|
||||
@@ -182,7 +179,7 @@ export const chatSlice = createSlice({
|
||||
},
|
||||
|
||||
addErrorMessage(
|
||||
state: SliceState,
|
||||
state,
|
||||
action: PayloadAction<{ id?: string; message: string }>,
|
||||
) {
|
||||
const { id, message } = action.payload;
|
||||
@@ -195,7 +192,7 @@ export const chatSlice = createSlice({
|
||||
});
|
||||
},
|
||||
|
||||
clearMessages(state: SliceState) {
|
||||
clearMessages(state) {
|
||||
state.messages = [];
|
||||
},
|
||||
},
|
||||
|
||||
@@ -43,6 +43,6 @@ export interface ObservationMessage {
|
||||
export interface StatusMessage {
|
||||
status_update: true;
|
||||
type: string;
|
||||
id?: string;
|
||||
id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
export const base64ToBlob = (base64: string) => {
|
||||
// Remove the prefix (e.g. data:image/png;base64,)
|
||||
const base64WithoutPrefix = base64.split(",")[1] || base64;
|
||||
const base64WithoutPrefix = base64.split(",")[1];
|
||||
|
||||
// Decode to bytes
|
||||
const bytes = atob(base64WithoutPrefix);
|
||||
|
||||
// Process in chunks to avoid memory issues
|
||||
const chunkSize = 8192; // Process 8KB at a time
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.slice(i, i + chunkSize);
|
||||
const array = new Uint8Array(chunk.length);
|
||||
|
||||
for (let j = 0; j < chunk.length; j += 1) {
|
||||
array[j] = chunk.charCodeAt(j);
|
||||
}
|
||||
|
||||
chunks.push(array);
|
||||
// Create an array of byte values
|
||||
const byteNumbers = new Array(bytes.length);
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
byteNumbers[i] = bytes.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Create a Blob from all chunks
|
||||
return new Blob(chunks, { type: "application/zip" });
|
||||
// Convert to Uint8Array
|
||||
const array = new Uint8Array(byteNumbers);
|
||||
|
||||
// Create a Blob
|
||||
return new Blob([array], { type: "application/zip" });
|
||||
};
|
||||
|
||||
1
frontend/src/utils/constants.ts
Normal file
1
frontend/src/utils/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MULTI_CONVO_UI_IS_ENABLED = false;
|
||||
@@ -1,15 +0,0 @@
|
||||
function loadFeatureFlag(
|
||||
flagName: string,
|
||||
defaultValue: boolean = false,
|
||||
): boolean {
|
||||
try {
|
||||
const stringValue =
|
||||
localStorage.getItem(`FEATURE_${flagName}`) || defaultValue.toString();
|
||||
const value = !!JSON.parse(stringValue);
|
||||
return value;
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export const MULTI_CONVERSATION_UI = loadFeatureFlag("MULTI_CONVERSATION_UI");
|
||||
@@ -24,7 +24,6 @@ from openhands.events.action import (
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
AgentCondensationObservation,
|
||||
AgentDelegateObservation,
|
||||
BrowserOutputObservation,
|
||||
CmdOutputObservation,
|
||||
@@ -37,7 +36,6 @@ from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.memory.condenser import Condenser
|
||||
from openhands.runtime.plugins import (
|
||||
AgentSkillsRequirement,
|
||||
JupyterRequirement,
|
||||
@@ -117,9 +115,6 @@ class CodeActAgent(Agent):
|
||||
disabled_microagents=self.config.disabled_microagents,
|
||||
)
|
||||
|
||||
self.condenser = Condenser.from_config(self.config.condenser)
|
||||
logger.debug(f'Using condenser: {self.condenser}')
|
||||
|
||||
def get_action_message(
|
||||
self,
|
||||
action: Action,
|
||||
@@ -327,9 +322,6 @@ class CodeActAgent(Agent):
|
||||
text = 'OBSERVATION:\n' + truncate_content(obs.content, max_message_chars)
|
||||
text += '\n[Last action has been rejected by the user]'
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif isinstance(obs, AgentCondensationObservation):
|
||||
text = truncate_content(obs.content, max_message_chars)
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
else:
|
||||
# If an observation message is not returned, it will cause an error
|
||||
# when the LLM tries to return the next message
|
||||
@@ -450,10 +442,7 @@ class CodeActAgent(Agent):
|
||||
|
||||
pending_tool_call_action_messages: dict[str, Message] = {}
|
||||
tool_call_id_to_message: dict[str, Message] = {}
|
||||
|
||||
# Condense the events from the state.
|
||||
events = self.condenser.condensed_history(state)
|
||||
|
||||
events = list(state.history)
|
||||
for event in events:
|
||||
# create a regular message from an event
|
||||
if isinstance(event, Action):
|
||||
|
||||
@@ -217,7 +217,7 @@ class AgentController:
|
||||
reported = RuntimeError(
|
||||
'There was an unexpected error while running the agent. Please '
|
||||
f'report this error to the developers. Your session ID is {self.id}. '
|
||||
f'Error type: {e.__class__.__name__}'
|
||||
f'Exception: {e}.'
|
||||
)
|
||||
if isinstance(e, litellm.AuthenticationError) or isinstance(
|
||||
e, litellm.BadRequestError
|
||||
@@ -993,12 +993,10 @@ class AgentController:
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f'AgentController(id={getattr(self, "id", "<uninitialized>")}, '
|
||||
f'agent={getattr(self, "agent", "<uninitialized>")!r}, '
|
||||
f'event_stream={getattr(self, "event_stream", "<uninitialized>")!r}, '
|
||||
f'state={getattr(self, "state", "<uninitialized>")!r}, '
|
||||
f'delegate={getattr(self, "delegate", "<uninitialized>")!r}, '
|
||||
f'_pending_action={getattr(self, "_pending_action", "<uninitialized>")!r})'
|
||||
f'AgentController(id={self.id}, agent={self.agent!r}, '
|
||||
f'event_stream={self.event_stream!r}, '
|
||||
f'state={self.state!r}, '
|
||||
f'delegate={self.delegate!r}, _pending_action={self._pending_action!r})'
|
||||
)
|
||||
|
||||
def _is_awaiting_observation(self):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from dataclasses import dataclass, field, fields
|
||||
from dataclasses import dataclass, fields
|
||||
|
||||
from openhands.core.config.condenser_config import CondenserConfig, NoOpCondenserConfig
|
||||
from openhands.core.config.config_utils import get_field_info
|
||||
|
||||
|
||||
@@ -19,7 +18,6 @@ class AgentConfig:
|
||||
llm_config: The name of the llm config to use. If specified, this will override global llm config.
|
||||
use_microagents: Whether to use microagents at all. Default is True.
|
||||
disabled_microagents: A list of microagents to disable. Default is None.
|
||||
condenser: Configuration for the memory condenser. Default is NoOpCondenserConfig.
|
||||
"""
|
||||
|
||||
codeact_enable_browsing: bool = True
|
||||
@@ -31,7 +29,6 @@ class AgentConfig:
|
||||
llm_config: str | None = None
|
||||
use_microagents: bool = True
|
||||
disabled_microagents: list[str] | None = None
|
||||
condenser: CondenserConfig = field(default_factory=NoOpCondenserConfig) # type: ignore
|
||||
|
||||
def defaults_to_dict(self) -> dict:
|
||||
"""Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
|
||||
|
||||
class NoOpCondenserConfig(BaseModel):
|
||||
"""Configuration for NoOpCondenser."""
|
||||
|
||||
type: Literal['noop'] = Field('noop')
|
||||
|
||||
|
||||
class ObservationMaskingCondenserConfig(BaseModel):
|
||||
"""Configuration for ObservationMaskingCondenser."""
|
||||
|
||||
type: Literal['observation_masking'] = Field('observation_masking')
|
||||
attention_window: int = Field(
|
||||
default=10,
|
||||
description='The number of most-recent events where observations will not be masked.',
|
||||
ge=1,
|
||||
)
|
||||
|
||||
|
||||
class RecentEventsCondenserConfig(BaseModel):
|
||||
"""Configuration for RecentEventsCondenser."""
|
||||
|
||||
type: Literal['recent'] = Field('recent')
|
||||
keep_first: int = Field(
|
||||
default=0,
|
||||
description='The number of initial events to condense.',
|
||||
ge=0,
|
||||
)
|
||||
max_events: int = Field(
|
||||
default=10, description='Maximum number of events to keep.', ge=1
|
||||
)
|
||||
|
||||
|
||||
class LLMSummarizingCondenserConfig(BaseModel):
|
||||
"""Configuration for LLMCondenser."""
|
||||
|
||||
type: Literal['llm'] = Field('llm')
|
||||
llm_config: LLMConfig = Field(
|
||||
..., description='Configuration for the LLM to use for condensing.'
|
||||
)
|
||||
|
||||
|
||||
class AmortizedForgettingCondenserConfig(BaseModel):
|
||||
"""Configuration for AmortizedForgettingCondenser."""
|
||||
|
||||
type: Literal['amortized'] = Field('amortized')
|
||||
max_size: int = Field(
|
||||
default=100,
|
||||
description='Maximum size of the condensed history before triggering forgetting.',
|
||||
ge=2,
|
||||
)
|
||||
keep_first: int = Field(
|
||||
default=0,
|
||||
description='Number of initial events to always keep in history.',
|
||||
ge=0,
|
||||
)
|
||||
|
||||
|
||||
class LLMAttentionCondenserConfig(BaseModel):
|
||||
"""Configuration for LLMAttentionCondenser."""
|
||||
|
||||
type: Literal['llm_attention'] = Field('llm_attention')
|
||||
llm_config: LLMConfig = Field(
|
||||
..., description='Configuration for the LLM to use for attention.'
|
||||
)
|
||||
max_size: int = Field(
|
||||
default=100,
|
||||
description='Maximum size of the condensed history before triggering forgetting.',
|
||||
ge=2,
|
||||
)
|
||||
keep_first: int = Field(
|
||||
default=0,
|
||||
description='Number of initial events to always keep in history.',
|
||||
ge=0,
|
||||
)
|
||||
|
||||
|
||||
CondenserConfig = (
|
||||
NoOpCondenserConfig
|
||||
| ObservationMaskingCondenserConfig
|
||||
| RecentEventsCondenserConfig
|
||||
| LLMSummarizingCondenserConfig
|
||||
| AmortizedForgettingCondenserConfig
|
||||
| LLMAttentionCondenserConfig
|
||||
)
|
||||
@@ -35,8 +35,6 @@ class SandboxConfig:
|
||||
remote_runtime_resource_factor: Factor to scale the resource allocation for remote runtime.
|
||||
Must be one of [1, 2, 4, 8]. Will only be used if the runtime is remote.
|
||||
enable_gpu: Whether to enable GPU.
|
||||
docker_runtime_kwargs: Additional keyword arguments to pass to the Docker runtime when running containers.
|
||||
This should be a JSON string that will be parsed into a dictionary.
|
||||
"""
|
||||
|
||||
remote_runtime_api_url: str = 'http://localhost:8000'
|
||||
@@ -60,10 +58,9 @@ class SandboxConfig:
|
||||
runtime_startup_env_vars: dict[str, str] = field(default_factory=dict)
|
||||
browsergym_eval_env: str | None = None
|
||||
platform: str | None = None
|
||||
close_delay: int = 900
|
||||
close_delay: int = 15
|
||||
remote_runtime_resource_factor: int = 1
|
||||
enable_gpu: bool = False
|
||||
docker_runtime_kwargs: str | None = None
|
||||
|
||||
def defaults_to_dict(self) -> dict:
|
||||
"""Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""
|
||||
|
||||
@@ -44,8 +44,5 @@ class ObservationTypeSchema(BaseModel):
|
||||
|
||||
USER_REJECTED: str = Field(default='user_rejected')
|
||||
|
||||
CONDENSE: str = Field(default='condense')
|
||||
"""Result of a condensation operation."""
|
||||
|
||||
|
||||
ObservationType = ObservationTypeSchema()
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
from openhands.events.observation.agent import (
|
||||
AgentCondensationObservation,
|
||||
AgentStateChangedObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.observation.browse import BrowserOutputObservation
|
||||
from openhands.events.observation.commands import (
|
||||
CmdOutputMetadata,
|
||||
@@ -35,5 +32,4 @@ __all__ = [
|
||||
'AgentDelegateObservation',
|
||||
'SuccessObservation',
|
||||
'UserRejectObservation',
|
||||
'AgentCondensationObservation',
|
||||
]
|
||||
|
||||
@@ -14,14 +14,3 @@ class AgentStateChangedObservation(Observation):
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentCondensationObservation(Observation):
|
||||
"""The output of a condensation action."""
|
||||
|
||||
observation: str = ObservationType.CONDENSE
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return self.content
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import copy
|
||||
|
||||
from openhands.events.observation.agent import (
|
||||
AgentCondensationObservation,
|
||||
AgentStateChangedObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.observation.browse import BrowserOutputObservation
|
||||
from openhands.events.observation.commands import (
|
||||
CmdOutputMetadata,
|
||||
@@ -35,7 +32,6 @@ observations = (
|
||||
ErrorObservation,
|
||||
AgentStateChangedObservation,
|
||||
UserRejectObservation,
|
||||
AgentCondensationObservation,
|
||||
)
|
||||
|
||||
OBSERVATION_TYPE_TO_CLASS = {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import asyncio
|
||||
import queue
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from queue import Queue
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -62,19 +61,12 @@ class EventStream:
|
||||
_subscribers: dict[str, dict[str, Callable]]
|
||||
_cur_id: int = 0
|
||||
_lock: threading.Lock
|
||||
_queue: queue.Queue[Event]
|
||||
_queue_thread: threading.Thread
|
||||
_queue_loop: asyncio.AbstractEventLoop | None
|
||||
_thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]]
|
||||
|
||||
def __init__(self, sid: str, file_store: FileStore):
|
||||
def __init__(self, sid: str, file_store: FileStore, num_workers: int = 1):
|
||||
self.sid = sid
|
||||
self.file_store = file_store
|
||||
self._stop_flag = threading.Event()
|
||||
self._queue: queue.Queue[Event] = queue.Queue()
|
||||
self._queue: Queue[Event] = Queue()
|
||||
self._thread_pools: dict[str, dict[str, ThreadPoolExecutor]] = {}
|
||||
self._thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]] = {}
|
||||
self._queue_loop = None
|
||||
self._queue_thread = threading.Thread(target=self._run_queue_loop)
|
||||
self._queue_thread.daemon = True
|
||||
self._queue_thread.start()
|
||||
@@ -99,54 +91,9 @@ class EventStream:
|
||||
if id >= self._cur_id:
|
||||
self._cur_id = id + 1
|
||||
|
||||
def _init_thread_loop(self, subscriber_id: str, callback_id: str):
|
||||
def _init_thread_loop(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
if subscriber_id not in self._thread_loops:
|
||||
self._thread_loops[subscriber_id] = {}
|
||||
self._thread_loops[subscriber_id][callback_id] = loop
|
||||
|
||||
def close(self):
|
||||
self._stop_flag.set()
|
||||
if self._queue_thread.is_alive():
|
||||
self._queue_thread.join()
|
||||
|
||||
subscriber_ids = list(self._subscribers.keys())
|
||||
for subscriber_id in subscriber_ids:
|
||||
callback_ids = list(self._subscribers[subscriber_id].keys())
|
||||
for callback_id in callback_ids:
|
||||
self._clean_up_subscriber(subscriber_id, callback_id)
|
||||
|
||||
def _clean_up_subscriber(self, subscriber_id: str, callback_id: str):
|
||||
if subscriber_id not in self._subscribers:
|
||||
logger.warning(f'Subscriber not found during cleanup: {subscriber_id}')
|
||||
return
|
||||
if callback_id not in self._subscribers[subscriber_id]:
|
||||
logger.warning(f'Callback not found during cleanup: {callback_id}')
|
||||
return
|
||||
if (
|
||||
subscriber_id in self._thread_loops
|
||||
and callback_id in self._thread_loops[subscriber_id]
|
||||
):
|
||||
loop = self._thread_loops[subscriber_id][callback_id]
|
||||
try:
|
||||
loop.stop()
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error closing loop for {subscriber_id}/{callback_id}: {e}'
|
||||
)
|
||||
del self._thread_loops[subscriber_id][callback_id]
|
||||
|
||||
if (
|
||||
subscriber_id in self._thread_pools
|
||||
and callback_id in self._thread_pools[subscriber_id]
|
||||
):
|
||||
pool = self._thread_pools[subscriber_id][callback_id]
|
||||
pool.shutdown()
|
||||
del self._thread_pools[subscriber_id][callback_id]
|
||||
|
||||
del self._subscribers[subscriber_id][callback_id]
|
||||
|
||||
def _get_filename_for_id(self, id: int) -> str:
|
||||
return get_conversation_event_filename(self.sid, id)
|
||||
@@ -229,8 +176,7 @@ class EventStream:
|
||||
def subscribe(
|
||||
self, subscriber_id: EventStreamSubscriber, callback: Callable, callback_id: str
|
||||
):
|
||||
initializer = partial(self._init_thread_loop, subscriber_id, callback_id)
|
||||
pool = ThreadPoolExecutor(max_workers=1, initializer=initializer)
|
||||
pool = ThreadPoolExecutor(max_workers=1, initializer=self._init_thread_loop)
|
||||
if subscriber_id not in self._subscribers:
|
||||
self._subscribers[subscriber_id] = {}
|
||||
self._thread_pools[subscriber_id] = {}
|
||||
@@ -252,7 +198,7 @@ class EventStream:
|
||||
logger.warning(f'Callback not found during unsubscribe: {callback_id}')
|
||||
return
|
||||
|
||||
self._clean_up_subscriber(subscriber_id, callback_id)
|
||||
del self._subscribers[subscriber_id][callback_id]
|
||||
|
||||
def add_event(self, event: Event, source: EventSource):
|
||||
if hasattr(event, '_id') and event.id is not None:
|
||||
@@ -271,20 +217,13 @@ class EventStream:
|
||||
self._queue.put(event)
|
||||
|
||||
def _run_queue_loop(self):
|
||||
self._queue_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._queue_loop)
|
||||
try:
|
||||
self._queue_loop.run_until_complete(self._process_queue())
|
||||
finally:
|
||||
self._queue_loop.close()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(self._process_queue())
|
||||
|
||||
async def _process_queue(self):
|
||||
while should_continue() and not self._stop_flag.is_set():
|
||||
event = None
|
||||
try:
|
||||
event = self._queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
while should_continue():
|
||||
event = self._queue.get()
|
||||
for key in sorted(self._subscribers.keys()):
|
||||
callbacks = self._subscribers[key]
|
||||
for callback_id in callbacks:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from openhands.memory.condenser import Condenser
|
||||
from openhands.memory.condenser import MemoryCondenser
|
||||
from openhands.memory.memory import LongTermMemory
|
||||
|
||||
__all__ = ['LongTermMemory', 'Condenser']
|
||||
__all__ = ['LongTermMemory', 'MemoryCondenser']
|
||||
|
||||
@@ -1,409 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from litellm import supports_response_schema
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import override
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config.condenser_config import (
|
||||
AmortizedForgettingCondenserConfig,
|
||||
CondenserConfig,
|
||||
LLMAttentionCondenserConfig,
|
||||
LLMSummarizingCondenserConfig,
|
||||
NoOpCondenserConfig,
|
||||
ObservationMaskingCondenserConfig,
|
||||
RecentEventsCondenserConfig,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import AgentCondensationObservation, Observation
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
CONDENSER_METADATA_KEY = 'condenser_meta'
|
||||
"""Key identifying where metadata is stored in a `State` object's `extra_data` field."""
|
||||
|
||||
class MemoryCondenser:
|
||||
def condense(self, summarize_prompt: str, llm: LLM):
|
||||
"""Attempts to condense the memory by using the llm
|
||||
|
||||
def get_condensation_metadata(state: State) -> list[dict[str, Any]]:
|
||||
"""Utility function to retrieve a list of metadata batches from a `State`.
|
||||
|
||||
Args:
|
||||
state: The state to retrieve metadata from.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: A list of metadata batches, each representing a condensation.
|
||||
"""
|
||||
if CONDENSER_METADATA_KEY in state.extra_data:
|
||||
return state.extra_data[CONDENSER_METADATA_KEY]
|
||||
return []
|
||||
|
||||
|
||||
class Condenser(ABC):
|
||||
"""Abstract condenser interface.
|
||||
|
||||
Condensers take a list of `Event` objects and reduce them into a potentially smaller list.
|
||||
|
||||
Agents can use condensers to reduce the amount of events they need to consider when deciding which action to take. To use a condenser, agents can call the `condensed_history` method on the current `State` being considered and use the results instead of the full history.
|
||||
|
||||
Example usage::
|
||||
|
||||
condenser = Condenser.from_config(condenser_config)
|
||||
events = condenser.condensed_history(state)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._metadata_batch: dict[str, Any] = {}
|
||||
|
||||
def add_metadata(self, key: str, value: Any) -> None:
|
||||
"""Add information to the current metadata batch.
|
||||
|
||||
Any key/value pairs added to the metadata batch will be recorded in the `State` at the end of the current condensation.
|
||||
|
||||
Args:
|
||||
key: The key to store the metadata under.
|
||||
|
||||
value: The metadata to store.
|
||||
"""
|
||||
self._metadata_batch[key] = value
|
||||
|
||||
def write_metadata(self, state: State) -> None:
|
||||
"""Write the current batch of metadata to the `State`.
|
||||
|
||||
Resets the current metadata batch: any metadata added after this call will be stored in a new batch and written to the `State` at the end of the next condensation.
|
||||
"""
|
||||
if CONDENSER_METADATA_KEY not in state.extra_data:
|
||||
state.extra_data[CONDENSER_METADATA_KEY] = []
|
||||
if self._metadata_batch:
|
||||
state.extra_data[CONDENSER_METADATA_KEY].append(self._metadata_batch)
|
||||
|
||||
# Since the batch has been written, clear it for the next condensation
|
||||
self._metadata_batch = {}
|
||||
|
||||
@contextmanager
|
||||
def metadata_batch(self, state: State):
|
||||
"""Context manager to ensure batched metadata is always written to the `State`."""
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.write_metadata(state)
|
||||
|
||||
@abstractmethod
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""Condense a sequence of events into a potentially smaller list.
|
||||
|
||||
New condenser strategies should override this method to implement their own condensation logic. Call `self.add_metadata` in the implementation to record any relevant per-condensation diagnostic information.
|
||||
|
||||
Args:
|
||||
events: A list of events representing the entire history of the agent.
|
||||
|
||||
Returns:
|
||||
list[Event]: An event sequence representing a condensed history of the agent.
|
||||
"""
|
||||
|
||||
def condensed_history(self, state: State) -> list[Event]:
|
||||
"""Condense the state's history."""
|
||||
with self.metadata_batch(state):
|
||||
return self.condense(state.history)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: CondenserConfig) -> Condenser:
|
||||
"""Create a condenser from a configuration object.
|
||||
|
||||
Args:
|
||||
config: Configuration for the condenser.
|
||||
|
||||
Returns:
|
||||
Condenser: A condenser instance.
|
||||
Parameters:
|
||||
- llm (LLM): llm to be used for summarization
|
||||
|
||||
Raises:
|
||||
ValueError: If the condenser type is not recognized.
|
||||
"""
|
||||
match config:
|
||||
case NoOpCondenserConfig():
|
||||
return NoOpCondenser()
|
||||
|
||||
case ObservationMaskingCondenserConfig():
|
||||
return ObservationMaskingCondenser(
|
||||
**config.model_dump(exclude=['type'])
|
||||
)
|
||||
|
||||
case RecentEventsCondenserConfig():
|
||||
return RecentEventsCondenser(**config.model_dump(exclude=['type']))
|
||||
|
||||
case LLMSummarizingCondenserConfig(llm_config=llm_config):
|
||||
return LLMSummarizingCondenser(llm=LLM(config=llm_config))
|
||||
|
||||
case AmortizedForgettingCondenserConfig():
|
||||
return AmortizedForgettingCondenser(
|
||||
**config.model_dump(exclude=['type'])
|
||||
)
|
||||
|
||||
case LLMAttentionCondenserConfig(llm_config=llm_config):
|
||||
return LLMAttentionCondenser(
|
||||
llm=LLM(config=llm_config),
|
||||
**config.model_dump(exclude=['type', 'llm_config']),
|
||||
)
|
||||
|
||||
case _:
|
||||
raise ValueError(f'Unknown condenser config: {config}')
|
||||
|
||||
|
||||
class RollingCondenser(Condenser, ABC):
|
||||
"""Base class for a specialized condenser strategy that applies condensation to a rolling history.
|
||||
|
||||
The rolling history is computed by appending new events to the most recent condensation. For example, the sequence of calls::
|
||||
|
||||
assert state.history == [event1, event2, event3]
|
||||
condensation = condenser.condensed_history(state)
|
||||
|
||||
# ...new events are added to the state...
|
||||
|
||||
assert state.history == [event1, event2, event3, event4, event5]
|
||||
condenser.condensed_history(state)
|
||||
|
||||
will result in second call to `condensed_history` passing `condensation + [event4, event5]` to the `condense` method.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._condensation: list[Event] = []
|
||||
self._last_history_length: int = 0
|
||||
|
||||
super().__init__()
|
||||
|
||||
@override
|
||||
def condensed_history(self, state: State) -> list[Event]:
|
||||
new_events = state.history[self._last_history_length :]
|
||||
|
||||
with self.metadata_batch(state):
|
||||
results = self.condense(self._condensation + new_events)
|
||||
|
||||
self._condensation = results
|
||||
self._last_history_length = len(state.history)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class NoOpCondenser(Condenser):
|
||||
"""A condenser that does nothing to the event sequence."""
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""Returns the list of events unchanged."""
|
||||
return events
|
||||
|
||||
|
||||
class ObservationMaskingCondenser(Condenser):
|
||||
"""A condenser that masks the values of observations outside of a recent attention window."""
|
||||
|
||||
def __init__(self, attention_window: int = 5):
|
||||
self.attention_window = attention_window
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""Replace the content of observations outside of the attention window with a placeholder."""
|
||||
results: list[Event] = []
|
||||
for i, event in enumerate(events):
|
||||
if (
|
||||
isinstance(event, Observation)
|
||||
and i < len(events) - self.attention_window
|
||||
):
|
||||
results.append(AgentCondensationObservation('<MASKED>'))
|
||||
else:
|
||||
results.append(event)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class RecentEventsCondenser(Condenser):
|
||||
"""A condenser that only keeps a certain number of the most recent events."""
|
||||
|
||||
def __init__(self, keep_first: int = 0, max_events: int = 10):
|
||||
self.keep_first = keep_first
|
||||
self.max_events = max_events
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""Keep only the most recent events (up to `max_events`)."""
|
||||
head = events[: self.keep_first]
|
||||
tail_length = max(0, self.max_events - len(head))
|
||||
tail = events[-tail_length:]
|
||||
return head + tail
|
||||
|
||||
|
||||
class LLMSummarizingCondenser(Condenser):
|
||||
"""A condenser that relies on a language model to summarize the event sequence as a single event."""
|
||||
|
||||
def __init__(self, llm: LLM):
|
||||
self.llm = llm
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""Applies an LLM to summarize the list of events.
|
||||
|
||||
Raises:
|
||||
Exception: If the LLM is unable to summarize the event sequence.
|
||||
- Exception: the same exception as it got from the llm or processing the response
|
||||
"""
|
||||
try:
|
||||
# Convert events to a format suitable for summarization
|
||||
events_text = '\n'.join(f'{e.timestamp}: {e.message}' for e in events)
|
||||
summarize_prompt = f'Please summarize these events:\n{events_text}'
|
||||
|
||||
resp = self.llm.completion(
|
||||
messages=[{'content': summarize_prompt, 'role': 'user'}]
|
||||
)
|
||||
summary_response = resp.choices[0].message.content
|
||||
|
||||
# Create a new summary event with the condensed content
|
||||
summary_event = AgentCondensationObservation(summary_response)
|
||||
|
||||
# Add metrics to state
|
||||
self.add_metadata('response', resp.model_dump())
|
||||
self.add_metadata('metrics', self.llm.metrics.get())
|
||||
|
||||
return [summary_event]
|
||||
|
||||
messages = [{'content': summarize_prompt, 'role': 'user'}]
|
||||
resp = llm.completion(messages=messages)
|
||||
summary_response = resp['choices'][0]['message']['content']
|
||||
return summary_response
|
||||
except Exception as e:
|
||||
logger.error('Error condensing events: %s', str(e), exc_info=False)
|
||||
raise e
|
||||
logger.error('Error condensing thoughts: %s', str(e), exc_info=False)
|
||||
|
||||
|
||||
class AmortizedForgettingCondenser(RollingCondenser):
|
||||
"""A condenser that maintains a condensed history and forgets old events when it grows too large."""
|
||||
|
||||
def __init__(self, max_size: int = 100, keep_first: int = 0):
|
||||
"""Initialize the condenser.
|
||||
|
||||
Args:
|
||||
max_size: Maximum size of history before forgetting.
|
||||
keep_first: Number of initial events to always keep.
|
||||
|
||||
Raises:
|
||||
ValueError: If keep_first is greater than max_size, keep_first is negative, or max_size is non-positive.
|
||||
"""
|
||||
if keep_first >= max_size // 2:
|
||||
raise ValueError(
|
||||
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
|
||||
)
|
||||
if keep_first < 0:
|
||||
raise ValueError(f'keep_first ({keep_first}) cannot be negative')
|
||||
if max_size < 1:
|
||||
raise ValueError(f'max_size ({keep_first}) cannot be non-positive')
|
||||
|
||||
self.max_size = max_size
|
||||
self.keep_first = keep_first
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""Apply the amortized forgetting strategy to the given list of events."""
|
||||
if len(events) <= self.max_size:
|
||||
return events
|
||||
|
||||
target_size = self.max_size // 2
|
||||
head = events[: self.keep_first]
|
||||
|
||||
events_from_tail = target_size - len(head)
|
||||
tail = events[-events_from_tail:]
|
||||
|
||||
return head + tail
|
||||
|
||||
|
||||
class ImportantEventSelection(BaseModel):
|
||||
"""Utility class for the `LLMAttentionCondenser` that forces the LLM to return a list of integers."""
|
||||
|
||||
ids: list[int]
|
||||
|
||||
|
||||
class LLMAttentionCondenser(RollingCondenser):
|
||||
"""Rolling condenser strategy that uses an LLM to select the most important events when condensing the history."""
|
||||
|
||||
def __init__(self, llm: LLM, max_size: int = 100, keep_first: int = 0):
|
||||
if keep_first >= max_size // 2:
|
||||
raise ValueError(
|
||||
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
|
||||
)
|
||||
if keep_first < 0:
|
||||
raise ValueError(f'keep_first ({keep_first}) cannot be negative')
|
||||
if max_size < 1:
|
||||
raise ValueError(f'max_size ({keep_first}) cannot be non-positive')
|
||||
|
||||
self.max_size = max_size
|
||||
self.keep_first = keep_first
|
||||
self.llm = llm
|
||||
|
||||
# This condenser relies on the `response_schema` feature, which is not supported by all LLMs
|
||||
if not supports_response_schema(
|
||||
model=self.llm.config.model,
|
||||
custom_llm_provider=self.llm.config.custom_llm_provider,
|
||||
):
|
||||
raise ValueError(
|
||||
"The LLM model must support the 'response_schema' parameter to use the LLMAttentionCondenser."
|
||||
)
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""If the history is too long, use an LLM to select the most important events."""
|
||||
if len(events) <= self.max_size:
|
||||
return events
|
||||
|
||||
target_size = self.max_size // 2
|
||||
head = events[: self.keep_first]
|
||||
|
||||
events_from_tail = target_size - len(head)
|
||||
|
||||
message: str = """You will be given a list of actions, observations, and thoughts from a coding agent.
|
||||
Each item in the list has an identifier. Please sort the identifiers in order of how important the
|
||||
contents of the item are for the next step of the coding agent's task, from most important to least
|
||||
important."""
|
||||
|
||||
response = self.llm.completion(
|
||||
messages=[
|
||||
{'content': message, 'role': 'user'},
|
||||
*[
|
||||
{
|
||||
'content': f'<ID>{e.id}</ID>\n<CONTENT>{e.message}</CONTENT>',
|
||||
'role': 'user',
|
||||
}
|
||||
for e in events
|
||||
],
|
||||
],
|
||||
response_format={
|
||||
'type': 'json_schema',
|
||||
'json_schema': {
|
||||
'name': 'ImportantEventSelection',
|
||||
'schema': ImportantEventSelection.model_json_schema(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response_ids = ImportantEventSelection.model_validate_json(
|
||||
response.choices[0].message.content
|
||||
).ids
|
||||
|
||||
self.add_metadata('all_event_ids', [event.id for event in events])
|
||||
self.add_metadata('response_ids', response_ids)
|
||||
self.add_metadata('metrics', self.llm.metrics.get())
|
||||
|
||||
# Filter out any IDs from the head and trim the results down
|
||||
head_ids = [event.id for event in head]
|
||||
response_ids = [
|
||||
response_id for response_id in response_ids if response_id not in head_ids
|
||||
][:events_from_tail]
|
||||
|
||||
# If the response IDs aren't _long_ enough, iterate backwards through the events and add any unfound IDs to the list.
|
||||
for event in reversed(events):
|
||||
if len(response_ids) >= events_from_tail:
|
||||
break
|
||||
if event.id not in response_ids:
|
||||
response_ids.append(event.id)
|
||||
|
||||
# Grab the events associated with the response IDs
|
||||
tail = [event for event in events if event.id in response_ids]
|
||||
|
||||
return head + tail
|
||||
# TODO If the llm fails with ContextWindowExceededError, we can try to condense the memory chunk by chunk
|
||||
raise
|
||||
|
||||
@@ -210,11 +210,9 @@ class Runtime(FileEditRuntimeMixin):
|
||||
source = event.source if event.source else EventSource.AGENT
|
||||
self.event_stream.add_event(observation, source) # type: ignore[arg-type]
|
||||
|
||||
def clone_repo(self, github_token: str, selected_repository: str):
|
||||
def clone_repo(self, github_token: str | None, selected_repository: str | None):
|
||||
if not github_token or not selected_repository:
|
||||
raise ValueError(
|
||||
'github_token and selected_repository must be provided to clone a repository'
|
||||
)
|
||||
return
|
||||
url = f'https://{github_token}@github.com/{selected_repository}.git'
|
||||
dir_name = selected_repository.split('/')[1]
|
||||
# add random branch name to avoid conflicts
|
||||
|
||||
@@ -26,17 +26,6 @@ class DockerRuntimeBuilder(RuntimeBuilder):
|
||||
|
||||
self.rolling_logger = RollingLogger(max_lines=10)
|
||||
|
||||
@staticmethod
|
||||
def check_buildx():
|
||||
"""Check if Docker Buildx is available"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker', 'buildx', 'version'], capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def build(
|
||||
self,
|
||||
path: str,
|
||||
@@ -73,38 +62,6 @@ class DockerRuntimeBuilder(RuntimeBuilder):
|
||||
'Docker server version must be >= 18.09 to use BuildKit'
|
||||
)
|
||||
|
||||
if not DockerRuntimeBuilder.check_buildx():
|
||||
# when running openhands in a container, there might not be a "docker"
|
||||
# binary available, in which case we need to download docker binary.
|
||||
# since the official openhands app image is built from debian, we use
|
||||
# debian way to install docker binary
|
||||
logger.info(
|
||||
'No docker binary available inside openhands-app container, trying to download online...'
|
||||
)
|
||||
commands = [
|
||||
'apt-get update',
|
||||
'apt-get install -y ca-certificates curl gnupg',
|
||||
'install -m 0755 -d /etc/apt/keyrings',
|
||||
'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc',
|
||||
'chmod a+r /etc/apt/keyrings/docker.asc',
|
||||
'echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||
tee /etc/apt/sources.list.d/docker.list > /dev/null',
|
||||
'apt-get update',
|
||||
'apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin',
|
||||
]
|
||||
for cmd in commands:
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd, shell=True, check=True, stdout=subprocess.DEVNULL
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f'Image build failed:\n{e}')
|
||||
logger.error(f'Command output:\n{e.output}')
|
||||
raise
|
||||
logger.info('Downloaded and installed docker binary')
|
||||
|
||||
target_image_hash_name = tags[0]
|
||||
target_image_repo, target_image_source_tag = target_image_hash_name.split(':')
|
||||
target_image_tag = tags[1].split(':')[1] if len(tags) > 1 else None
|
||||
|
||||
@@ -267,11 +267,13 @@ class DockerRuntime(ActionExecutionClient):
|
||||
environment=environment,
|
||||
volumes=volumes,
|
||||
device_requests=(
|
||||
[docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)]
|
||||
[docker.types.DeviceRequest(
|
||||
capabilities=[['gpu']],
|
||||
count=-1
|
||||
)]
|
||||
if self.config.sandbox.enable_gpu
|
||||
else None
|
||||
),
|
||||
**(self.config.sandbox.docker_runtime_kwargs or {}),
|
||||
)
|
||||
self.log('debug', f'Container started. Server url: {self.api_url}')
|
||||
self.send_status_message('STATUS$CONTAINER_STARTED')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
@@ -24,11 +23,11 @@ def split_bash_commands(commands):
|
||||
return ['']
|
||||
try:
|
||||
parsed = bashlex.parse(commands)
|
||||
except (bashlex.errors.ParsingError, NotImplementedError):
|
||||
except bashlex.errors.ParsingError as e:
|
||||
logger.debug(
|
||||
f'Failed to parse bash commands\n'
|
||||
f'[input]: {commands}\n'
|
||||
f'[warning]: {traceback.format_exc()}\n'
|
||||
f'[warning]: {e}\n'
|
||||
f'The original command will be returned as is.'
|
||||
)
|
||||
# If parsing fails, return the original commands
|
||||
@@ -144,13 +143,9 @@ def escape_bash_special_chars(command: str) -> str:
|
||||
remaining = command[last_pos:]
|
||||
parts.append(remaining)
|
||||
return ''.join(parts)
|
||||
except (bashlex.errors.ParsingError, NotImplementedError):
|
||||
logger.debug(
|
||||
f'Failed to parse bash commands for special characters escape\n'
|
||||
f'[input]: {command}\n'
|
||||
f'[warning]: {traceback.format_exc()}\n'
|
||||
f'The original command will be returned as is.'
|
||||
)
|
||||
except bashlex.errors.ParsingError:
|
||||
# Fallback if parsing fails
|
||||
logger.warning(f'Failed to parse command: {command}')
|
||||
return command
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def get_user_id(request: Request) -> int:
|
||||
return getattr(request.state, 'github_user_id', 0)
|
||||
|
||||
|
||||
def get_sid_from_token(token: str, jwt_secret: str) -> str:
|
||||
"""Retrieves the session id from a JWT token.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import jwt
|
||||
from github import Github, GithubException
|
||||
from socketio.exceptions import ConnectionRefusedError
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -18,6 +18,7 @@ from openhands.server.routes.settings import ConversationStoreImpl, SettingsStor
|
||||
from openhands.server.session.manager import ConversationDoesNotExistError
|
||||
from openhands.server.shared import config, openhands_config, session_manager, sio
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
|
||||
@sio.event
|
||||
@@ -30,20 +31,27 @@ async def connect(connection_id: str, environ, auth):
|
||||
logger.error('No conversation_id in query params')
|
||||
raise ConnectionRefusedError('No conversation_id in query params')
|
||||
|
||||
user_id = -1
|
||||
github_token = ''
|
||||
if openhands_config.app_mode != AppMode.OSS:
|
||||
cookies_str = environ.get('HTTP_COOKIE', '')
|
||||
cookies = dict(cookie.split('=', 1) for cookie in cookies_str.split('; '))
|
||||
signed_token = cookies.get('github_auth', '')
|
||||
if not signed_token:
|
||||
logger.error('No github_auth cookie')
|
||||
raise ConnectionRefusedError('No github_auth cookie')
|
||||
decoded = jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
|
||||
user_id = decoded['github_user_id']
|
||||
user_id = ''
|
||||
if auth and 'github_token' in auth:
|
||||
github_token = auth['github_token']
|
||||
try:
|
||||
with Github(github_token) as g:
|
||||
gh_user = await call_sync_from_async(g.get_user)
|
||||
user_id = gh_user.id
|
||||
except GithubException as e:
|
||||
logger.error(f'Error connecting to github: {e}')
|
||||
raise ConnectionRefusedError(f'Error connecting to github: {e}')
|
||||
else:
|
||||
logger.error('No github_token in auth')
|
||||
raise ConnectionRefusedError('No github_token sent')
|
||||
|
||||
logger.info(f'User {user_id} is connecting to conversation {conversation_id}')
|
||||
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, github_token
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
if metadata.github_user_id != user_id:
|
||||
logger.error(
|
||||
@@ -53,7 +61,7 @@ async def connect(connection_id: str, environ, auth):
|
||||
f'User {user_id} is not allowed to join conversation {conversation_id}'
|
||||
)
|
||||
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
settings = await settings_store.load()
|
||||
|
||||
if not settings:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.server.shared import openhands_config
|
||||
@@ -9,9 +7,6 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
|
||||
GITHUB_API_BASE = 'https://api.github.com'
|
||||
GITHUB_API_VERSION = '2022-11-28'
|
||||
|
||||
|
||||
@app.get('/github/repositories')
|
||||
async def get_github_repositories(
|
||||
@@ -69,143 +64,3 @@ async def get_github_repositories(
|
||||
json_response.headers['Link'] = response.headers['Link']
|
||||
|
||||
return json_response
|
||||
|
||||
|
||||
@app.get('/github/installations')
|
||||
async def get_github_installations(request: Request):
|
||||
"""Get GitHub App installations for the authenticated user"""
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {github_token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get, f'{GITHUB_API_BASE}/user/installations', headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error fetching installations: {str(e)}',
|
||||
)
|
||||
|
||||
return JSONResponse(content=response.json())
|
||||
|
||||
|
||||
@app.get('/github/user')
|
||||
async def get_github_user(request: Request):
|
||||
"""Get authenticated GitHub user information"""
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {github_token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get, f'{GITHUB_API_BASE}/user', headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error fetching user: {str(e)}',
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
return JSONResponse(
|
||||
content={
|
||||
'id': data['id'],
|
||||
'login': data['login'],
|
||||
'avatar_url': data['avatar_url'],
|
||||
'company': data.get('company'),
|
||||
'name': data.get('name'),
|
||||
'email': data.get('email'),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get('/github/search/repositories')
|
||||
async def search_github_repositories(
|
||||
request: Request,
|
||||
query: str,
|
||||
per_page: int = Query(default=5, le=100),
|
||||
sort: Literal['', 'updated', 'stars', 'forks'] = 'stars',
|
||||
order: Literal['desc', 'asc'] = 'desc',
|
||||
):
|
||||
"""Search public GitHub repositories"""
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {github_token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
}
|
||||
|
||||
params = {'q': query, 'per_page': str(per_page), 'sort': sort, 'order': order}
|
||||
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get,
|
||||
f'{GITHUB_API_BASE}/search/repositories',
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error searching repositories: {str(e)}',
|
||||
)
|
||||
|
||||
return JSONResponse(content=response.json())
|
||||
|
||||
|
||||
@app.get('/github/repos/{owner}/{repo}/commits')
|
||||
async def get_github_commits(
|
||||
request: Request, owner: str, repo: str, per_page: int = Query(default=1, le=100)
|
||||
):
|
||||
"""Get latest commits for a GitHub repository"""
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {github_token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
}
|
||||
|
||||
params = {'per_page': str(per_page)}
|
||||
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get,
|
||||
f'{GITHUB_API_BASE}/repos/{owner}/{repo}/commits',
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
response = getattr(e, 'response', None)
|
||||
if response and response.status_code == 409:
|
||||
# Repository is empty, no commits yet
|
||||
return JSONResponse(content=[])
|
||||
raise HTTPException(
|
||||
status_code=getattr(getattr(e, 'response', None), 'status_code', 500),
|
||||
detail=f'Error fetching commits: {str(e)}',
|
||||
)
|
||||
|
||||
return JSONResponse(content=response.json())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user