mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
82 Commits
openhands-
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61439dfa39 | ||
|
|
92b8d55c2d | ||
|
|
a125b6cd43 | ||
|
|
01ac207b92 | ||
|
|
6d015a5dca | ||
|
|
275512305d | ||
|
|
3a4bc10b29 | ||
|
|
bbd31b32f3 | ||
|
|
295c6fd629 | ||
|
|
5a809c9b53 | ||
|
|
0b74fd71d9 | ||
|
|
4fa5c329d6 | ||
|
|
5b1dcf83a6 | ||
|
|
b9884f7609 | ||
|
|
99eda0e571 | ||
|
|
5832463088 | ||
|
|
045ec2b95d | ||
|
|
23473070b9 | ||
|
|
63133c0ba9 | ||
|
|
2023fb767f | ||
|
|
23f40a1c01 | ||
|
|
873dddb4e8 | ||
|
|
fe50cd1f9f | ||
|
|
516e2da520 | ||
|
|
1dd6f544bc | ||
|
|
40c52feb5b | ||
|
|
f31ccad48b | ||
|
|
828d169b82 | ||
|
|
a622d27016 | ||
|
|
5507b131fe | ||
|
|
0f102e4c71 | ||
|
|
157a1a24f6 | ||
|
|
fcfbcb64d4 | ||
|
|
931792e87a | ||
|
|
ee701eacc2 | ||
|
|
8907fed78e | ||
|
|
3cc20a2576 | ||
|
|
01cf0d433c | ||
|
|
f6bed82ae2 | ||
|
|
3eae2e2aca | ||
|
|
b45fc522c7 | ||
|
|
0d409c8c24 | ||
|
|
5458ebbd7d | ||
|
|
c411a29db4 | ||
|
|
386e04a2ba | ||
|
|
62c4bab6ba | ||
|
|
e308b6fb6f | ||
|
|
27a660fb6b | ||
|
|
27d761a1fe | ||
|
|
8028e2c2dd | ||
|
|
ff9058e28a | ||
|
|
c45caaef1f | ||
|
|
a3c107daa4 | ||
|
|
040839bdd1 | ||
|
|
aabbbb6c6a | ||
|
|
9747c9e9f8 | ||
|
|
bb85542aca | ||
|
|
6e4ff56934 | ||
|
|
561f308401 | ||
|
|
3733c646af | ||
|
|
cf0f6e5e38 | ||
|
|
77aa843d53 | ||
|
|
affbc49b08 | ||
|
|
9016b9c434 | ||
|
|
e3a96097ba | ||
|
|
6a41a3cb4f | ||
|
|
d1555e093c | ||
|
|
5469d5311d | ||
|
|
eaf4c610b2 | ||
|
|
aad7a612c1 | ||
|
|
23425c85aa | ||
|
|
fb53ae43c0 | ||
|
|
1f8a0180d3 | ||
|
|
8cfcdd7ba3 | ||
|
|
9515ac5e62 | ||
|
|
cebd391b7a | ||
|
|
343b86429e | ||
|
|
09734467c0 | ||
|
|
17d722f3b3 | ||
|
|
e310f6b776 | ||
|
|
5626a22e42 | ||
|
|
cde8aad47f |
4
.github/workflows/ghcr-build.yml
vendored
4
.github/workflows/ghcr-build.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
docker-images: false
|
||||
swap-storage: true
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.2.0
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
docker-images: false
|
||||
swap-storage: true
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.2.0
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
|
||||
@@ -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 [PRs](https://github.com/All-Hands-AI/OpenHands/pulls), with the evaluation process varying based on the type of change:
|
||||
We're generally happy to consider all pull requests 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, 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:
|
||||
Once the setup is complete, this command starts both the backend and frontend servers, 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, 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.
|
||||
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.
|
||||
|
||||
### 7. Help
|
||||
Need assistance or information on available targets and commands? The help command provides all the necessary guidance to ensure a smooth experience with OpenHands.
|
||||
Need help or info on available targets and commands? Use the help command for all the guidance you need 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.18-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.20-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.18-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -34,7 +34,7 @@ workspace_base = "./workspace"
|
||||
|
||||
# Path to store trajectories, can be a folder or a file
|
||||
# If it's a folder, the session id will be used as the file name
|
||||
#trajectories_path="./trajectories"
|
||||
#save_trajectory_path="./trajectories"
|
||||
|
||||
# File store path
|
||||
#file_store_path = "/tmp/file_store"
|
||||
|
||||
@@ -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.18-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.20-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#
|
||||
|
||||
services:
|
||||
openhands:
|
||||
build:
|
||||
@@ -7,8 +7,8 @@ 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.18-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ~/.openhands-state:/.openhands-state
|
||||
- ${WORKSPACE_BASE:-$PWD/workspace}:/opt/workspace_base
|
||||
pull_policy: build
|
||||
stdin_open: true
|
||||
@@ -94,7 +94,7 @@ Les options de configuration de base sont définies dans la section `[core]` du
|
||||
- Description : Désactiver la couleur dans la sortie du terminal
|
||||
|
||||
**Trajectoires**
|
||||
- `trajectories_path`
|
||||
- `save_trajectory_path`
|
||||
- Type : `str`
|
||||
- Valeur par défaut : `"./trajectories"`
|
||||
- Description : Chemin pour stocker les trajectoires (peut être un dossier ou un fichier). Si c'est un dossier, les trajectoires seront enregistrées dans un fichier nommé avec l'ID de session et l'extension .json, dans ce dossier.
|
||||
|
||||
@@ -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.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -61,7 +61,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
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.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -56,6 +56,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -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.18-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20
|
||||
```
|
||||
|
||||
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -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.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
- 描述: 禁用终端输出中的颜色
|
||||
|
||||
**轨迹**
|
||||
- `trajectories_path`
|
||||
- `save_trajectory_path`
|
||||
- 类型: `str`
|
||||
- 默认值: `"./trajectories"`
|
||||
- 描述: 存储轨迹的路径(可以是文件夹或文件)。如果是文件夹,轨迹将保存在该文件夹中以会话 ID 命名的 .json 文件中。
|
||||
|
||||
@@ -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.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -59,7 +59,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
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.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -57,6 +57,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -7,53 +7,11 @@ If you are running in [GUI Mode](https://docs.all-hands.dev/modules/usage/how-to
|
||||
take precedence.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
# Table of Contents
|
||||
|
||||
- [Core Configuration](#core-configuration)
|
||||
- [API Keys](#api-keys)
|
||||
- [Workspace](#workspace)
|
||||
- [Debugging and Logging](#debugging-and-logging)
|
||||
- [Session Management](#session-management)
|
||||
- [Trajectories](#trajectories)
|
||||
- [File Store](#file-store)
|
||||
- [Task Management](#task-management)
|
||||
- [Sandbox Configuration](#sandbox-configuration)
|
||||
- [Miscellaneous](#miscellaneous)
|
||||
- [LLM Configuration](#llm-configuration)
|
||||
- [AWS Credentials](#aws-credentials)
|
||||
- [API Configuration](#api-configuration)
|
||||
- [Custom LLM Provider](#custom-llm-provider)
|
||||
- [Embeddings](#embeddings)
|
||||
- [Message Handling](#message-handling)
|
||||
- [Model Selection](#model-selection)
|
||||
- [Retrying](#retrying)
|
||||
- [Advanced Options](#advanced-options)
|
||||
- [Agent Configuration](#agent-configuration)
|
||||
- [Microagent Configuration](#microagent-configuration)
|
||||
- [Memory Configuration](#memory-configuration)
|
||||
- [LLM Configuration](#llm-configuration-2)
|
||||
- [ActionSpace Configuration](#actionspace-configuration)
|
||||
- [Microagent Usage](#microagent-usage)
|
||||
- [Sandbox Configuration](#sandbox-configuration)
|
||||
- [Execution](#execution)
|
||||
- [Container Image](#container-image)
|
||||
- [Networking](#networking)
|
||||
- [Linting and Plugins](#linting-and-plugins)
|
||||
- [Dependencies and Environment](#dependencies-and-environment)
|
||||
- [Evaluation](#evaluation)
|
||||
- [Security Configuration](#security-configuration)
|
||||
- [Confirmation Mode](#confirmation-mode)
|
||||
- [Security Analyzer](#security-analyzer)
|
||||
|
||||
---
|
||||
|
||||
## Core Configuration
|
||||
|
||||
The core configuration options are defined in the `[core]` section of the `config.toml` file.
|
||||
|
||||
**API Keys**
|
||||
### API Keys
|
||||
- `e2b_api_key`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -69,7 +27,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `""`
|
||||
- Description: API token secret for Modal
|
||||
|
||||
**Workspace**
|
||||
### Workspace
|
||||
- `workspace_base`
|
||||
- Type: `str`
|
||||
- Default: `"./workspace"`
|
||||
@@ -80,7 +38,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `"/tmp/cache"`
|
||||
- Description: Cache directory path
|
||||
|
||||
**Debugging and Logging**
|
||||
### Debugging and Logging
|
||||
- `debug`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
@@ -91,13 +49,13 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `false`
|
||||
- Description: Disable color in terminal output
|
||||
|
||||
**Trajectories**
|
||||
- `trajectories_path`
|
||||
### Trajectories
|
||||
- `save_trajectory_path`
|
||||
- Type: `str`
|
||||
- Default: `"./trajectories"`
|
||||
- Description: Path to store trajectories (can be a folder or a file). If it's a folder, the trajectories will be saved in a file named with the session id name and .json extension, in that folder.
|
||||
|
||||
**File Store**
|
||||
### File Store
|
||||
- `file_store_path`
|
||||
- Type: `str`
|
||||
- Default: `"/tmp/file_store"`
|
||||
@@ -128,7 +86,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `[".*"]`
|
||||
- Description: List of allowed file extensions for uploads
|
||||
|
||||
**Task Management**
|
||||
### Task Management
|
||||
- `max_budget_per_task`
|
||||
- Type: `float`
|
||||
- Default: `0.0`
|
||||
@@ -139,7 +97,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `100`
|
||||
- Description: Maximum number of iterations
|
||||
|
||||
**Sandbox Configuration**
|
||||
### Sandbox Configuration
|
||||
- `workspace_mount_path_in_sandbox`
|
||||
- Type: `str`
|
||||
- Default: `"/workspace"`
|
||||
@@ -155,7 +113,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `""`
|
||||
- Description: Path to rewrite the workspace mount path to. You can usually ignore this, it refers to special cases of running inside another container.
|
||||
|
||||
**Miscellaneous**
|
||||
### Miscellaneous
|
||||
- `run_as_openhands`
|
||||
- Type: `bool`
|
||||
- Default: `true`
|
||||
@@ -182,7 +140,7 @@ The LLM (Large Language Model) configuration options are defined in the `[llm]`
|
||||
|
||||
To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LLM_NUM_RETRIES`.
|
||||
|
||||
**AWS Credentials**
|
||||
### AWS Credentials
|
||||
- `aws_access_key_id`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -198,7 +156,7 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `""`
|
||||
- Description: AWS secret access key
|
||||
|
||||
**API Configuration**
|
||||
### API Configuration
|
||||
- `api_key`
|
||||
- Type: `str`
|
||||
- Default: `None`
|
||||
@@ -224,13 +182,13 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `0.0`
|
||||
- Description: Cost per output token
|
||||
|
||||
**Custom LLM Provider**
|
||||
### Custom LLM Provider
|
||||
- `custom_llm_provider`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: Custom LLM provider
|
||||
|
||||
**Embeddings**
|
||||
### Embeddings
|
||||
- `embedding_base_url`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -246,7 +204,7 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `"local"`
|
||||
- Description: Embedding model to use
|
||||
|
||||
**Message Handling**
|
||||
### Message Handling
|
||||
- `max_message_chars`
|
||||
- Type: `int`
|
||||
- Default: `30000`
|
||||
@@ -262,13 +220,13 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `0`
|
||||
- Description: Maximum number of output tokens
|
||||
|
||||
**Model Selection**
|
||||
### Model Selection
|
||||
- `model`
|
||||
- Type: `str`
|
||||
- Default: `"claude-3-5-sonnet-20241022"`
|
||||
- Description: Model to use
|
||||
|
||||
**Retrying**
|
||||
### Retrying
|
||||
- `num_retries`
|
||||
- Type: `int`
|
||||
- Default: `8`
|
||||
@@ -289,7 +247,7 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `2.0`
|
||||
- Description: Multiplier for exponential backoff calculation
|
||||
|
||||
**Advanced Options**
|
||||
### Advanced Options
|
||||
- `drop_params`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
@@ -329,13 +287,13 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
|
||||
The agent configuration options are defined in the `[agent]` and `[agent.<agent_name>]` sections of the `config.toml` file.
|
||||
|
||||
**Microagent Configuration**
|
||||
### Microagent Configuration
|
||||
- `micro_agent_name`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: Name of the micro agent to use for this agent
|
||||
|
||||
**Memory Configuration**
|
||||
### Memory Configuration
|
||||
- `memory_enabled`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
@@ -346,13 +304,13 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
|
||||
- Default: `3`
|
||||
- Description: The maximum number of threads indexing at the same time for embeddings
|
||||
|
||||
**LLM Configuration**
|
||||
### LLM Configuration
|
||||
- `llm_config`
|
||||
- Type: `str`
|
||||
- Default: `'your-llm-config-group'`
|
||||
- Description: The name of the LLM config to use
|
||||
|
||||
**ActionSpace Configuration**
|
||||
### ActionSpace Configuration
|
||||
- `function_calling`
|
||||
- Type: `bool`
|
||||
- Default: `true`
|
||||
@@ -373,7 +331,7 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
|
||||
- Default: `false`
|
||||
- Description: Whether Jupyter is enabled in the action space
|
||||
|
||||
**Microagent Usage**
|
||||
### Microagent Usage
|
||||
- `use_microagents`
|
||||
- Type: `bool`
|
||||
- Default: `true`
|
||||
@@ -390,7 +348,7 @@ The sandbox configuration options are defined in the `[sandbox]` section of the
|
||||
|
||||
To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-e SANDBOX_TIMEOUT`.
|
||||
|
||||
**Execution**
|
||||
### Execution
|
||||
- `timeout`
|
||||
- Type: `int`
|
||||
- Default: `120`
|
||||
@@ -401,19 +359,19 @@ To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-
|
||||
- Default: `1000`
|
||||
- Description: Sandbox user ID
|
||||
|
||||
**Container Image**
|
||||
### Container Image
|
||||
- `base_container_image`
|
||||
- Type: `str`
|
||||
- Default: `"nikolaik/python-nodejs:python3.12-nodejs22"`
|
||||
- Description: Container image to use for the sandbox
|
||||
|
||||
**Networking**
|
||||
### Networking
|
||||
- `use_host_network`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
- Description: Use host network
|
||||
|
||||
**Linting and Plugins**
|
||||
### Linting and Plugins
|
||||
- `enable_auto_lint`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
@@ -424,7 +382,7 @@ To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-
|
||||
- Default: `true`
|
||||
- Description: Whether to initialize plugins
|
||||
|
||||
**Dependencies and Environment**
|
||||
### Dependencies and Environment
|
||||
- `runtime_extra_deps`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -435,7 +393,7 @@ To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-
|
||||
- Default: `{}`
|
||||
- Description: Environment variables to set at the launch of the runtime
|
||||
|
||||
**Evaluation**
|
||||
### Evaluation
|
||||
- `browsergym_eval_env`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -447,13 +405,13 @@ The security configuration options are defined in the `[security]` section of th
|
||||
|
||||
To use these with the docker command, pass in `-e SECURITY_<option>`. Example: `-e SECURITY_CONFIRMATION_MODE`.
|
||||
|
||||
**Confirmation Mode**
|
||||
### Confirmation Mode
|
||||
- `confirmation_mode`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
- Description: Enable confirmation mode
|
||||
|
||||
**Security Analyzer**
|
||||
### Security Analyzer
|
||||
- `security_analyzer`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
|
||||
@@ -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.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -45,7 +45,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
@@ -58,7 +58,7 @@ Here are some examples of CLI commands and their expected outputs:
|
||||
### Example 1: Simple Task
|
||||
|
||||
```bash
|
||||
How can I help? >> Write a Python script that prints "Hello, World!"
|
||||
>> Write a Python script that prints "Hello, World!"
|
||||
```
|
||||
|
||||
Expected Output:
|
||||
@@ -72,7 +72,7 @@ Expected Output:
|
||||
### Example 2: Bash Command
|
||||
|
||||
```bash
|
||||
How can I help? >> Create a directory named "test_dir"
|
||||
>> Create a directory named "test_dir"
|
||||
```
|
||||
|
||||
Expected Output:
|
||||
@@ -86,7 +86,7 @@ Expected Output:
|
||||
### Example 3: Error Handling
|
||||
|
||||
```bash
|
||||
How can I help? >> Delete a non-existent file
|
||||
>> Delete a non-existent file
|
||||
```
|
||||
|
||||
Expected Output:
|
||||
|
||||
@@ -58,3 +58,32 @@ sandbox_base_container_image="custom-image"
|
||||
### Run
|
||||
|
||||
Run OpenHands by running ```make run``` in the top level directory.
|
||||
|
||||
## Using Docker
|
||||
|
||||
If you're using OpenHands via Docker, you can specify a custom sandbox container by setting the `SANDBOX_BASE_CONTAINER_IMAGE` environment variable when running the Docker container.
|
||||
|
||||
Here's an example command:
|
||||
|
||||
```bash
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_BASE_CONTAINER_IMAGE=custom-image \
|
||||
-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.20
|
||||
```
|
||||
|
||||
Make sure to replace `custom-image` with the name of your custom Docker image. The image should be available in your local Docker environment before running this command.
|
||||
|
||||
> Note: This feature is available from version 0.20.0 onwards.
|
||||
|
||||
### Command Explanation
|
||||
|
||||
- `-e SANDBOX_BASE_CONTAINER_IMAGE=custom-image`: Specifies your custom sandbox container image
|
||||
- `-v /var/run/docker.sock:/var/run/docker.sock`: Allows OpenHands to create and manage Docker containers
|
||||
- `-v ~/.openhands-state:/.openhands-state`: Persists OpenHands state between runs
|
||||
- `--add-host host.docker.internal:host-gateway`: Required for Docker-in-Docker communication
|
||||
|
||||
@@ -76,18 +76,18 @@ When using OpenHands in online mode, the GitHub OAuth flow:
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
1. **Token Not Recognized**:
|
||||
- **Token Not Recognized**:
|
||||
- Ensure the token is properly saved in settings.
|
||||
- Check that the token hasn't expired.
|
||||
- Verify the token has the required scopes.
|
||||
- Try regenerating the token.
|
||||
|
||||
2. **Organization Access Denied**:
|
||||
- **Organization Access Denied**:
|
||||
- Check if SSO is required but not enabled.
|
||||
- Verify organization membership.
|
||||
- Contact organization admin if token policies are blocking access.
|
||||
|
||||
3. **Verifying Token Works**:
|
||||
- **Verifying Token Works**:
|
||||
- The app will show a green checkmark if the token is valid.
|
||||
- Try accessing a repository to confirm permissions.
|
||||
- Check the browser console for any error messages.
|
||||
|
||||
@@ -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.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -43,7 +43,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Persisting Session Data
|
||||
|
||||
Using the standard Development Workflow, the session data is stored in memory. Currently, if OpenHands' service is restarted,
|
||||
previous sessions become invalid (a new secret is generated) and thus not recoverable.
|
||||
|
||||
## How to Persist Session Data
|
||||
|
||||
### Development Workflow
|
||||
In the `config.toml` file, specify the following:
|
||||
```
|
||||
[core]
|
||||
...
|
||||
file_store="local"
|
||||
file_store_path="/absolute/path/to/openhands/cache/directory"
|
||||
jwt_secret="secretpass"
|
||||
```
|
||||
@@ -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.18-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.20
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
@@ -5,23 +5,14 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
|
||||
## Model Recommendations
|
||||
|
||||
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some
|
||||
recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and
|
||||
[this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent).
|
||||
|
||||
When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings:
|
||||
|
||||
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
|
||||
- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task.
|
||||
- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models.
|
||||
|
||||
Please refer to the [full article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) for more details.
|
||||
recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
|
||||
|
||||
Based on these findings and community feedback, the following models have been verified to work reasonably well with OpenHands:
|
||||
|
||||
- claude-3-5-sonnet (recommended)
|
||||
- gpt-4 / gpt-4o
|
||||
- llama-3.1-405b
|
||||
- deepseek-v2.5
|
||||
- anthropic/claude-3-5-sonnet-20241022 (recommended)
|
||||
- anthropic/claude-3-5-haiku-20241022
|
||||
- deepseek/deepseek-chat
|
||||
- gpt-4o
|
||||
|
||||
:::warning
|
||||
OpenHands will issue many prompts to the LLM you configure. Most of these LLMs cost money, so be sure to set spending
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## Repository Configuration
|
||||
|
||||
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.
|
||||
|
||||
We suggest including the following information:
|
||||
- **Repository Overview**: A brief description of your project's purpose and architecture.
|
||||
- **Directory Structure**: Key directories and their purposes.
|
||||
- **Development Guidelines**: Project-specific coding standards and practices.
|
||||
- **Testing Requirements**: How to run tests and what types of tests are required.
|
||||
- **Setup Instructions**: Steps needed to build and run the project.
|
||||
|
||||
### Example Repository Configuration
|
||||
Example `.openhands/microagents/repo.md` file:
|
||||
```
|
||||
Repository: MyProject
|
||||
Description: A web application for task management
|
||||
|
||||
Directory Structure:
|
||||
- src/: Main application code
|
||||
- tests/: Test files
|
||||
- docs/: Documentation
|
||||
|
||||
Setup:
|
||||
- Run `npm install` to install dependencies
|
||||
- Use `npm run dev` for development
|
||||
- Run `npm test` for testing
|
||||
|
||||
Guidelines:
|
||||
- Follow ESLint configuration
|
||||
- Write tests for all new features
|
||||
- Use TypeScript for new code
|
||||
```
|
||||
|
||||
### Customizing Prompts
|
||||
|
||||
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.
|
||||
- **Specify Testing Requirements**: Include project-specific testing requirements in your prompts.
|
||||
|
||||
Example customized prompt:
|
||||
```
|
||||
Add a new task completion feature to src/components/TaskList.tsx following our existing component patterns.
|
||||
Include unit tests in tests/components/ and update the documentation in docs/features/.
|
||||
The component should use our shared styling from src/styles/components.
|
||||
```
|
||||
|
||||
### Best Practices for Repository Customization
|
||||
|
||||
- **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.
|
||||
36
docs/modules/usage/prompting/microagents-overview.md
Normal file
36
docs/modules/usage/prompting/microagents-overview.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Microagents Overview
|
||||
|
||||
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge, repository-specific context
|
||||
and task-specific workflows. They help by providing expert guidance, automating common tasks, and ensuring
|
||||
consistent practices across projects.
|
||||
|
||||
## Microagent Types
|
||||
|
||||
Currently OpenHands supports the following types of microagents:
|
||||
|
||||
* [Repository Microagents](./microagents-repo): Repository-specific context and guidelines for OpenHands.
|
||||
* [Public Microagents](./microagents-public): General guidelines triggered by keywords for all OpenHands users.
|
||||
|
||||
When OpenHands works with a repository, it:
|
||||
|
||||
1. Loads repository-specific instructions from `.openhands/microagents/` if present in the repository.
|
||||
2. Loads general guidelines triggered by keywords in conversations.
|
||||
See current [Public Microagents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
|
||||
|
||||
## Microagent Format
|
||||
|
||||
All microagents use markdown files with YAML frontmatter that have special instructions to help OpenHands accomplish
|
||||
tasks:
|
||||
```
|
||||
---
|
||||
name: <Name of the microagent>
|
||||
type: <MicroAgent type>
|
||||
version: <MicroAgent version>
|
||||
agent: <The agent type (Typically CodeActAgent)>
|
||||
triggers:
|
||||
- <Optional keywords triggering the microagent. If triggers are removed, it will always be included>
|
||||
---
|
||||
|
||||
<Markdown with any special guidelines, instructions, and prompts that OpenHands should follow.
|
||||
Check out the specific documentation for each microagent on best practices for more information.>
|
||||
```
|
||||
153
docs/modules/usage/prompting/microagents-public.md
Normal file
153
docs/modules/usage/prompting/microagents-public.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Public Microagents
|
||||
|
||||
## Overview
|
||||
|
||||
Public microagents are specialized guidelines triggered by keywords for all OpenHands users.
|
||||
They are defined in markdown files under the
|
||||
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) directory.
|
||||
|
||||
Public microagents:
|
||||
- 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.
|
||||
|
||||
## Current Public Microagents
|
||||
|
||||
For more information about specific microagents, refer to their individual documentation files in
|
||||
the [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/) directory.
|
||||
|
||||
### GitHub Agent
|
||||
**File**: `github.md`
|
||||
**Triggers**: `github`, `git`
|
||||
|
||||
The GitHub agent specializes in GitHub API interactions and repository management. It:
|
||||
- Has access to a `GITHUB_TOKEN` for API authentication.
|
||||
- Follows strict guidelines for repository interactions.
|
||||
- Handles branch management and pull requests.
|
||||
- Uses the GitHub API instead of web browser interactions.
|
||||
|
||||
Key features:
|
||||
- Branch protection (prevents direct pushes to main/master)
|
||||
- Automated PR creation
|
||||
- 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`
|
||||
|
||||
Specializes in handling npm package management with specific focus on:
|
||||
- Non-interactive shell operations.
|
||||
- Automated confirmation handling using Unix 'yes' command.
|
||||
- Package installation automation.
|
||||
|
||||
Usage Example:
|
||||
|
||||
```bash
|
||||
yes | npm install package-name
|
||||
```
|
||||
|
||||
## Contributing a Public Microagent
|
||||
|
||||
You can create your own public microagents by adding new markdown files to the
|
||||
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/) directory.
|
||||
|
||||
### Public Microagents Best Practices
|
||||
|
||||
- **Clear Scope**: Keep the microagent 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 microagent interacts with other components.
|
||||
|
||||
### Steps to Contribute a Public Microagent
|
||||
|
||||
#### 1. Plan the Public Microagent
|
||||
|
||||
Before creating a public microagent, 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?
|
||||
- What constraints or guidelines should it follow?
|
||||
|
||||
#### 2. Create File
|
||||
|
||||
Create a new markdown file in [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/)
|
||||
with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
|
||||
|
||||
Update the file with the required frontmatter [according to the required format](./microagents-overview#microagent-format)
|
||||
and the required specialized guidelines while following the [best practices above](#public-microagents-best-practices).
|
||||
|
||||
#### 3. Testing the Public Microagent
|
||||
|
||||
- Test the agent with various prompts.
|
||||
- Verify trigger words activate the agent correctly.
|
||||
- Ensure instructions are clear and comprehensive.
|
||||
- Check for potential conflicts with existing agents.
|
||||
|
||||
#### 4. Submission Process
|
||||
|
||||
Submit a pull request with:
|
||||
- The new microagent file.
|
||||
- Updated documentation if needed.
|
||||
- Description of the agent's purpose and capabilities.
|
||||
|
||||
### Example Public Microagent Implementation
|
||||
|
||||
Here's a template for a new microagent:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: docker
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- docker
|
||||
- container
|
||||
---
|
||||
|
||||
You are responsible for Docker container management and Dockerfile creation.
|
||||
|
||||
Key responsibilities:
|
||||
1. Create and modify Dockerfiles
|
||||
2. Manage container lifecycle
|
||||
3. Handle Docker Compose configurations
|
||||
|
||||
Guidelines:
|
||||
- Always use official base images when possible
|
||||
- Include necessary security considerations
|
||||
- Follow Docker best practices for layer optimization
|
||||
|
||||
Examples:
|
||||
1. Creating a Dockerfile:
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
CMD ["npm", "start"]
|
||||
|
||||
2. Docker Compose usage:
|
||||
version: '3'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
Remember to:
|
||||
- Validate Dockerfile syntax
|
||||
- Check for security vulnerabilities
|
||||
- Optimize for build time and image size
|
||||
```
|
||||
|
||||
See the [current public micro-agents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for
|
||||
more examples.
|
||||
68
docs/modules/usage/prompting/microagents-repo.md
Normal file
68
docs/modules/usage/prompting/microagents-repo.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Repository Microagents
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
|
||||
## Creating a Repository Micro-Agent
|
||||
|
||||
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
|
||||
`.openhands/microagents/repo.md`, which includes instructions that will
|
||||
be given to the agent every time it works with this repository.
|
||||
|
||||
### Repository Microagents Best Practices
|
||||
|
||||
- **Keep Instructions Updated**: Regularly update your `.openhands/microagents/` 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.
|
||||
|
||||
### Steps to Create a Repository Microagent
|
||||
|
||||
#### 1. Plan the Repository Microagent
|
||||
When creating a repository-specific micro-agent, we suggest including the following information:
|
||||
- **Repository Overview**: A brief description of your project's purpose and architecture.
|
||||
- **Directory Structure**: Key directories and their purposes.
|
||||
- **Development Guidelines**: Project-specific coding standards and practices.
|
||||
- **Testing Requirements**: How to run tests and what types of tests are required.
|
||||
- **Setup Instructions**: Steps needed to build and run the project.
|
||||
|
||||
#### 2. Create File
|
||||
|
||||
Create a file in your repository under `.openhands/microagents/` (Example: `.openhands/microagents/repo.md`)
|
||||
|
||||
Update the file with the required frontmatter [according to the required format](./microagents-overview#microagent-format)
|
||||
and the required specialized guidelines for your repository.
|
||||
|
||||
### Example Repository Microagent
|
||||
|
||||
```
|
||||
---
|
||||
name: repo
|
||||
type: repo
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
Repository: MyProject
|
||||
Description: A web application for task management
|
||||
|
||||
Directory Structure:
|
||||
- src/: Main application code
|
||||
- tests/: Test files
|
||||
- docs/: Documentation
|
||||
|
||||
Setup:
|
||||
- Run `npm install` to install dependencies
|
||||
- Use `npm run dev` for development
|
||||
- Run `npm test` for testing
|
||||
|
||||
Guidelines:
|
||||
- Follow ESLint configuration
|
||||
- Write tests for all new features
|
||||
- Use TypeScript for new code
|
||||
|
||||
If adding a new component in src/components, always add appropriate unit tests in tests/components/.
|
||||
```
|
||||
@@ -1,210 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
|
||||
## Available Micro-Agents
|
||||
|
||||
### GitHub Agent
|
||||
**File**: `github.md`
|
||||
**Triggers**: `github`, `git`
|
||||
|
||||
The GitHub agent specializes in GitHub API interactions and repository management. It:
|
||||
- Has access to a `GITHUB_TOKEN` for API authentication.
|
||||
- Follows strict guidelines for repository interactions.
|
||||
- Handles branch management and pull requests.
|
||||
- Uses the GitHub API instead of web browser interactions.
|
||||
|
||||
Key features:
|
||||
- Branch protection (prevents direct pushes to main/master)
|
||||
- Automated PR creation
|
||||
- Git configuration management
|
||||
- API-first approach for GitHub operations
|
||||
|
||||
### NPM Agent
|
||||
**File**: `npm.md`
|
||||
**Triggers**: `npm`
|
||||
|
||||
Specializes in handling npm package management with specific focus on:
|
||||
- Non-interactive shell operations.
|
||||
- Automated confirmation handling using Unix 'yes' command.
|
||||
- Package installation automation.
|
||||
|
||||
### Custom Micro-Agents
|
||||
|
||||
You can create your own micro-agents by adding new markdown files to the micro-agents directory.
|
||||
Each file should follow this structure:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: agent_name
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- trigger_word1
|
||||
- trigger_word2
|
||||
---
|
||||
|
||||
Instructions and capabilities for the micro-agent...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
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.
|
||||
|
||||
## Integration
|
||||
|
||||
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.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# GitHub agent example
|
||||
git checkout -b feature-branch
|
||||
git commit -m "Add new feature"
|
||||
git push origin feature-branch
|
||||
|
||||
# NPM agent example
|
||||
yes | npm install package-name
|
||||
```
|
||||
|
||||
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?
|
||||
- What constraints or guidelines should it follow?
|
||||
|
||||
### 2. File Structure
|
||||
|
||||
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
|
||||
|
||||
Your micro-agent file must include:
|
||||
|
||||
- **Front Matter**: YAML metadata at the start of the file:
|
||||
```markdown
|
||||
---
|
||||
name: your_agent_name
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- trigger_word1
|
||||
- trigger_word2
|
||||
---
|
||||
```
|
||||
|
||||
- **Instructions**: Clear, specific guidelines for the agent's behavior:
|
||||
```markdown
|
||||
You are responsible for [specific task/domain].
|
||||
|
||||
Key responsibilities:
|
||||
1. [Responsibility 1]
|
||||
2. [Responsibility 2]
|
||||
|
||||
Guidelines:
|
||||
- [Guideline 1]
|
||||
- [Guideline 2]
|
||||
|
||||
Examples of usage:
|
||||
[Example 1]
|
||||
[Example 2]
|
||||
```
|
||||
|
||||
### 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.
|
||||
- Verify trigger words activate the agent correctly.
|
||||
- Ensure instructions are clear and comprehensive.
|
||||
- Check for potential conflicts with existing agents.
|
||||
|
||||
### 6. Example Implementation
|
||||
|
||||
Here's a template for a new micro-agent:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: docker
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- docker
|
||||
- container
|
||||
---
|
||||
|
||||
You are responsible for Docker container management and Dockerfile creation.
|
||||
|
||||
Key responsibilities:
|
||||
1. Create and modify Dockerfiles
|
||||
2. Manage container lifecycle
|
||||
3. Handle Docker Compose configurations
|
||||
|
||||
Guidelines:
|
||||
- Always use official base images when possible
|
||||
- Include necessary security considerations
|
||||
- Follow Docker best practices for layer optimization
|
||||
|
||||
Examples:
|
||||
1. Creating a Dockerfile:
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
CMD ["npm", "start"]
|
||||
|
||||
2. Docker Compose usage:
|
||||
version: '3'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
Remember to:
|
||||
- Validate Dockerfile syntax
|
||||
- Check for security vulnerabilities
|
||||
- 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.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
1322
docs/package-lock.json
generated
1322
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.6.3",
|
||||
"@docusaurus/plugin-content-pages": "^3.6.3",
|
||||
"@docusaurus/preset-classic": "^3.6.3",
|
||||
"@docusaurus/theme-mermaid": "^3.6.3",
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/plugin-content-pages": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.7.0",
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@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.6.3",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.7.2"
|
||||
},
|
||||
|
||||
@@ -23,15 +23,26 @@ const sidebars: SidebarsConfig = {
|
||||
id: 'usage/prompting/prompting-best-practices',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Customization',
|
||||
id: 'usage/prompting/customization',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
type: 'category',
|
||||
label: 'Microagents',
|
||||
id: 'usage/prompting/microagents',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Overview',
|
||||
id: 'usage/prompting/microagents-overview',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Repository',
|
||||
id: 'usage/prompting/microagents-repo',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Public',
|
||||
id: 'usage/prompting/microagents-public',
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -126,11 +137,6 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Custom Sandbox',
|
||||
id: 'usage/how-to/custom-sandbox-guide',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Persist Session Data',
|
||||
id: 'usage/how-to/persist-session-data',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 app.py --server.port 8501 --server.address 0.0.0.0
|
||||
streamlit run app.py --server.port 8501 --server.address 0.0.0.0
|
||||
```
|
||||
|
||||
Then you can access the SWE-Bench trajectory visualizer at `localhost:8501`.
|
||||
|
||||
@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
get_metrics,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
@@ -148,6 +149,7 @@ 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
|
||||
@@ -448,7 +450,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 = state.metrics.get() if state.metrics else None
|
||||
metrics = get_metrics(state)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_config(
|
||||
run_as_openhands=False,
|
||||
max_budget_per_task=4,
|
||||
max_iterations=100,
|
||||
trajectories_path=os.path.join(
|
||||
save_trajectory_path=os.path.join(
|
||||
mount_path_on_host, f'traj_{task_short_name}.json'
|
||||
),
|
||||
sandbox=SandboxConfig(
|
||||
|
||||
@@ -17,6 +17,10 @@ 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,
|
||||
@@ -33,6 +37,7 @@ 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):
|
||||
@@ -45,11 +50,17 @@ 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):
|
||||
@@ -57,6 +68,11 @@ 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)
|
||||
|
||||
@@ -192,6 +208,7 @@ 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('@', '-')
|
||||
@@ -222,6 +239,9 @@ 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}')
|
||||
@@ -551,3 +571,10 @@ 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
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { retrieveLatestGitHubCommit } from "../../src/api/github";
|
||||
|
||||
describe("retrieveLatestGitHubCommit", () => {
|
||||
const { githubGetMock } = vi.hoisted(() => ({
|
||||
githubGetMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/api/github-axios-instance", () => ({
|
||||
github: {
|
||||
get: githubGetMock,
|
||||
},
|
||||
}));
|
||||
|
||||
it("should return the latest commit when repository has commits", async () => {
|
||||
const mockCommit = {
|
||||
sha: "123abc",
|
||||
commit: {
|
||||
message: "Initial commit",
|
||||
},
|
||||
};
|
||||
|
||||
githubGetMock.mockResolvedValueOnce({
|
||||
data: [mockCommit],
|
||||
});
|
||||
|
||||
const result = await retrieveLatestGitHubCommit("user/repo");
|
||||
expect(result).toEqual(mockCommit);
|
||||
});
|
||||
|
||||
it("should return null when repository is empty", async () => {
|
||||
const error = new Error("Repository is empty");
|
||||
(error as any).response = { status: 409 };
|
||||
githubGetMock.mockRejectedValueOnce(error);
|
||||
|
||||
const result = await retrieveLatestGitHubCommit("user/empty-repo");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw error for other error cases", async () => {
|
||||
const error = new Error("Network error");
|
||||
(error as any).response = { status: 500 };
|
||||
githubGetMock.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(retrieveLatestGitHubCommit("user/repo")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,13 @@ 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();
|
||||
const onDelete = vi.fn();
|
||||
const onChangeTitle = vi.fn();
|
||||
const onDownloadWorkspace = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -17,11 +19,11 @@ describe("ConversationCard", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
|
||||
@@ -33,53 +35,34 @@ describe("ConversationCard", () => {
|
||||
within(card).getByText(expectedDate);
|
||||
});
|
||||
|
||||
it("should render the repo if available", () => {
|
||||
it("should render the selectedRepository if available", () => {
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-repo"),
|
||||
screen.queryByTestId("conversation-card-selected-repository"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("conversation-card-repo");
|
||||
});
|
||||
|
||||
it("should call onClick when the card is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByTestId("conversation-card");
|
||||
await user.click(card);
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
screen.getByTestId("conversation-card-selected-repository");
|
||||
});
|
||||
|
||||
it("should toggle a context menu when clicking the ellipsis button", async () => {
|
||||
@@ -87,11 +70,11 @@ describe("ConversationCard", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -111,12 +94,12 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -131,21 +114,23 @@ describe("ConversationCard", () => {
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clicking the repo should not trigger the onClick handler", async () => {
|
||||
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const repo = screen.getByTestId("conversation-card-repo");
|
||||
await user.click(repo);
|
||||
const selectedRepository = screen.getByTestId(
|
||||
"conversation-card-selected-repository",
|
||||
);
|
||||
await user.click(selectedRepository);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -154,16 +139,24 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="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 ");
|
||||
@@ -171,21 +164,24 @@ 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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
await clickOnEditButton(user);
|
||||
|
||||
const title = screen.getByTestId("conversation-card-title");
|
||||
|
||||
await user.clear(title);
|
||||
@@ -199,12 +195,12 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -218,12 +214,12 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -238,37 +234,151 @@ describe("ConversationCard", () => {
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onDownloadWorkspace when the download button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
onDownloadWorkspace={onDownloadWorkspace}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const downloadButton = within(menu).getByTestId("download-button");
|
||||
|
||||
await user.click(downloadButton);
|
||||
|
||||
expect(onDownloadWorkspace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not display the edit or delete options if the handler is not provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
expect(screen.queryByTestId("edit-button")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument();
|
||||
|
||||
// toggle to hide the context menu
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
expect(screen.queryByTestId("edit-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("delete-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the ellipsis button if there are no actions", () => {
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
onDownloadWorkspace={onDownloadWorkspace}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onDownloadWorkspace={onDownloadWorkspace}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDownloadWorkspace={onDownloadWorkspace}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("ellipsis-button")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("state indicator", () => {
|
||||
it("should render the 'cold' indicator by default", () => {
|
||||
it("should render the 'STOPPED' indicator by default", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("cold-indicator");
|
||||
screen.getByTestId("STOPPED-indicator");
|
||||
});
|
||||
|
||||
it("should render the other indicators when provided", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
state="warm"
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
status="RUNNING"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("warm-indicator");
|
||||
expect(screen.queryByTestId("STOPPED-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("RUNNING-indicator");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,15 +6,23 @@ import {
|
||||
QueryClientConfig,
|
||||
} from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
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();
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <ConversationPanel onClose={onCloseMock} />,
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
|
||||
const renderConversationPanel = (config?: QueryClientConfig) =>
|
||||
render(<ConversationPanel onClose={onCloseMock} />, {
|
||||
render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={new QueryClient(config)}>
|
||||
@@ -52,6 +60,8 @@ 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);
|
||||
});
|
||||
|
||||
@@ -169,13 +179,15 @@ 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", {
|
||||
name: "Conversation 1 Renamed",
|
||||
title: "Conversation 1 Renamed",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,6 +208,8 @@ 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();
|
||||
@@ -217,51 +231,4 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { GitHubRepositorySelector } from "#/components/features/github/github-repo-selector";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import * as GitHubAPI from "#/api/github";
|
||||
|
||||
describe("GitHubRepositorySelector", () => {
|
||||
const onInputChangeMock = vi.fn();
|
||||
@@ -60,8 +59,8 @@ describe("GitHubRepositorySelector", () => {
|
||||
];
|
||||
|
||||
const searchPublicRepositoriesSpy = vi.spyOn(
|
||||
GitHubAPI,
|
||||
"searchPublicRepositories",
|
||||
OpenHands,
|
||||
"searchGitHubRepositories",
|
||||
);
|
||||
searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos);
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
|
||||
|
||||
const renderSidebar = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -18,7 +20,7 @@ const renderSidebar = () => {
|
||||
};
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should have the conversation panel open by default",
|
||||
() => {
|
||||
renderSidebar();
|
||||
@@ -26,7 +28,7 @@ describe("Sidebar", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should toggle the conversation panel",
|
||||
async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -43,4 +45,126 @@ describe("Sidebar", () => {
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
describe("Settings", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch settings data on mount", () => {
|
||||
renderSidebar();
|
||||
expect(getSettingsSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should send all settings data when saving AI configuration", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
"save-settings-button",
|
||||
);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
// the actual values are falsey (null or "") but we're checking for undefined
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: undefined,
|
||||
security_analyzer: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should send all settings data when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const menu = screen.getByTestId("account-settings-context-menu");
|
||||
const accountSettingsButton = within(menu).getByTestId(
|
||||
"account-settings-button",
|
||||
);
|
||||
await user.click(accountSettingsButton);
|
||||
|
||||
const accountSettingsModal = screen.getByTestId("account-settings-form");
|
||||
const saveButton =
|
||||
within(accountSettingsModal).getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
llm_api_key: undefined, // null or undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("should not reset AI configuration when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const menu = screen.getByTestId("account-settings-context-menu");
|
||||
const accountSettingsButton = within(menu).getByTestId(
|
||||
"account-settings-button",
|
||||
);
|
||||
await user.click(accountSettingsButton);
|
||||
|
||||
const accountSettingsModal = screen.getByTestId("account-settings-form");
|
||||
|
||||
const languageInput =
|
||||
within(accountSettingsModal).getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput =
|
||||
within(accountSettingsModal).getByLabelText(/github token/i);
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const saveButton =
|
||||
within(accountSettingsModal).getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
language: "no",
|
||||
llm_api_key: undefined, // null or undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("should not send the api key if its SET", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
|
||||
const apiKeyInput = within(settingsModal).getByLabelText(/api key/i);
|
||||
await user.type(apiKeyInput, "SET");
|
||||
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
"save-settings-button",
|
||||
);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: undefined,
|
||||
security_analyzer: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
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");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "123",
|
||||
});
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => (
|
||||
<SettingsForm
|
||||
settings={DEFAULT_SETTINGS}
|
||||
models={["anthropic/claude-3-5-sonnet-20241022", "model2"]}
|
||||
agents={["CodeActAgent", "agent2"]}
|
||||
securityAnalyzers={["analyzer1", "analyzer2"]}
|
||||
onClose={onCloseMock}
|
||||
/>
|
||||
),
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub />);
|
||||
|
||||
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
|
||||
await user.click(toggleAdvancedMode);
|
||||
|
||||
await screen.findByTestId("runtime-size");
|
||||
});
|
||||
|
||||
it("should not submit the form if required fields are empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub />);
|
||||
|
||||
expect(screen.queryByTestId("custom-model-input")).not.toBeInTheDocument();
|
||||
|
||||
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
|
||||
await user.click(toggleAdvancedMode);
|
||||
|
||||
const customModelInput = screen.getByTestId("custom-model-input");
|
||||
expect(customModelInput).toBeInTheDocument();
|
||||
|
||||
await user.clear(customModelInput);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
43
frontend/__tests__/context/ws-client-provider.test.tsx
Normal file
43
frontend/__tests__/context/ws-client-provider.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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'
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error including translation id when present", () => {
|
||||
const message = "We have a problem!"
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage")
|
||||
updateStatusWhenErrorMessagePresent({message, data: {msg_id: '..id..'}})
|
||||
|
||||
expect(addErrorMessageSpy).toHaveBeenCalledWith({
|
||||
message,
|
||||
id: '..id..',
|
||||
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_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
describe("App", () => {
|
||||
const RouteStub = createRoutesStub([
|
||||
@@ -35,7 +35,7 @@ describe("App", () => {
|
||||
await screen.findByTestId("app-route");
|
||||
});
|
||||
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should call endSession if the user does not have permission to view conversation",
|
||||
async () => {
|
||||
const errorToastSpy = vi.spyOn(toast, "error");
|
||||
@@ -59,10 +59,11 @@ describe("App", () => {
|
||||
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "9999",
|
||||
lastUpdated: "",
|
||||
name: "",
|
||||
repo: "",
|
||||
state: "cold",
|
||||
last_updated_at: "",
|
||||
created_at: "",
|
||||
title: "",
|
||||
selected_repository: "",
|
||||
status: "STOPPED",
|
||||
});
|
||||
const { rerender } = renderWithProviders(
|
||||
<RouteStub initialEntries={["/conversation/9999"]} />,
|
||||
|
||||
1256
frontend/package-lock.json
generated
1256
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.18.0",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -8,36 +8,36 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@nextui-org/react": "^2.6.10",
|
||||
"@nextui-org/react": "^2.6.11",
|
||||
"@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.62.12",
|
||||
"@tanstack/react-query": "^5.63.0",
|
||||
"@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.0",
|
||||
"i18next": "^24.2.1",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"isbot": "^5.1.19",
|
||||
"isbot": "^5.1.21",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.203.3",
|
||||
"posthog-js": "^1.205.0",
|
||||
"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.1",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.4",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"socket.io-client": "^4.8.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.15",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.9",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.16",
|
||||
"@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.2",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
@@ -97,20 +97,20 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.3.0",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^1.6.0"
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { github } from "./github-axios-instance";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
|
||||
/**
|
||||
* 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 github.get<GithubAppInstallation>(
|
||||
"/user/installations",
|
||||
);
|
||||
|
||||
return response.data.installations.map((installation) => installation.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves repositories where OpenHands Github App has been installed
|
||||
* @param installationIndex Pagination cursor position for app installation IDs
|
||||
@@ -82,72 +69,3 @@ export const retrieveGitHubUserRepositories = async (
|
||||
|
||||
return { data: response.data, nextPage };
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a GitHub token, retrieves the authenticated user
|
||||
* @returns The authenticated user or an error response
|
||||
*/
|
||||
export const retrieveGitHubUser = async () => {
|
||||
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 (
|
||||
query: string,
|
||||
per_page = 5,
|
||||
sort: "" | "updated" | "stars" | "forks" = "stars",
|
||||
order: "desc" | "asc" = "desc",
|
||||
): Promise<GitHubRepository[]> => {
|
||||
const response = await github.get<{ items: GitHubRepository[] }>(
|
||||
"/search/repositories",
|
||||
{
|
||||
params: {
|
||||
q: query,
|
||||
per_page,
|
||||
sort,
|
||||
order,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const retrieveLatestGitHubCommit = async (
|
||||
repository: string,
|
||||
): Promise<GitHubCommit | null> => {
|
||||
try {
|
||||
const response = await github.get<GitHubCommit[]>(
|
||||
`/repos/${repository}/commits`,
|
||||
{
|
||||
params: {
|
||||
per_page: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data[0] || null;
|
||||
} catch (error) {
|
||||
if (!error || typeof error !== "object") {
|
||||
throw new Error("Unknown error occurred");
|
||||
}
|
||||
const axiosError = error as { response?: { status: number } };
|
||||
if (axiosError.response?.status === 409) {
|
||||
// Repository is empty, no commits yet
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GetVSCodeUrlResponse,
|
||||
AuthenticateResponse,
|
||||
Conversation,
|
||||
ResultSet,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
@@ -222,8 +223,10 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
const { data } = await openHands.get<Conversation[]>("/api/conversations");
|
||||
return data;
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/conversations?limit=9",
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
|
||||
static async deleteUserConversation(conversationId: string): Promise<void> {
|
||||
@@ -232,9 +235,9 @@ class OpenHands {
|
||||
|
||||
static async updateUserConversation(
|
||||
conversationId: string,
|
||||
conversation: Partial<Omit<Conversation, "id">>,
|
||||
conversation: Partial<Omit<Conversation, "conversation_id">>,
|
||||
): Promise<void> {
|
||||
await openHands.put(`/api/conversations/${conversationId}`, conversation);
|
||||
await openHands.patch(`/api/conversations/${conversationId}`, conversation);
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
@@ -312,6 +315,45 @@ class OpenHands {
|
||||
const data = await openHands.post("/api/settings", settings);
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
static async getGitHubUser(): Promise<GitHubUser> {
|
||||
const response = await openHands.get<GitHubUser>("/api/github/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;
|
||||
}
|
||||
|
||||
static async getGitHubUserInstallationIds(): Promise<number[]> {
|
||||
const response = await openHands.get<number[]>("/api/github/installations");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async searchGitHubRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
): Promise<GitHubRepository[]> {
|
||||
const response = await openHands.get<{ items: GitHubRepository[] }>(
|
||||
"/api/github/search/repositories",
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
per_page,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.items;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectState } from "#/components/features/conversation-panel/conversation-state-indicator";
|
||||
import { ProjectStatus } from "#/components/features/conversation-panel/conversation-state-indicator";
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
@@ -62,8 +62,14 @@ export interface AuthenticateResponse {
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string;
|
||||
state: ProjectState;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ export function AccountSettingsContextMenu({
|
||||
ref={ref}
|
||||
className="absolute left-full -top-1 z-10"
|
||||
>
|
||||
<ContextMenuListItem onClick={onClickAccountSettings}>
|
||||
<ContextMenuListItem
|
||||
testId="account-settings-button"
|
||||
onClick={onClickAccountSettings}
|
||||
>
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ContextMenu({
|
||||
<ul
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
className={cn("bg-[#404040] rounded-md w-[224px]", className)}
|
||||
className={cn("bg-[#404040] rounded-md w-[140px]", className)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
import { useParams } from "react-router";
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { AgentControlBar } from "./agent-control-bar";
|
||||
import { AgentStatusBar } from "./agent-status-bar";
|
||||
import { ProjectMenuCard } from "../project-menu/ProjectMenuCard";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { RootState } from "#/store";
|
||||
import { SecurityLock } from "./security-lock";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
import { ConversationCard } from "../conversation-panel/conversation-card";
|
||||
import { DownloadModal } from "#/components/shared/download-modal";
|
||||
|
||||
interface ControlsProps {
|
||||
setSecurityOpen: (isOpen: boolean) => void;
|
||||
showSecurityLock: boolean;
|
||||
lastCommitData: GitHubCommit | null;
|
||||
}
|
||||
|
||||
export function Controls({
|
||||
setSecurityOpen,
|
||||
showSecurityLock,
|
||||
lastCommitData,
|
||||
}: ControlsProps) {
|
||||
const { gitHubToken } = useAuth();
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
|
||||
const params = useParams();
|
||||
const { data: conversation } = useUserConversation(
|
||||
params.conversationId ?? null,
|
||||
);
|
||||
|
||||
const projectMenuCardData = React.useMemo(
|
||||
() =>
|
||||
selectedRepository && lastCommitData
|
||||
? {
|
||||
repoName: selectedRepository,
|
||||
lastCommit: lastCommitData,
|
||||
avatar: null, // TODO: fetch repo avatar
|
||||
}
|
||||
: null,
|
||||
[selectedRepository, lastCommitData],
|
||||
);
|
||||
const [downloading, setDownloading] = React.useState(false);
|
||||
|
||||
const handleDownloadWorkspace = () => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
setDownloading(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -46,9 +37,19 @@ export function Controls({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProjectMenuCard
|
||||
isConnectedToGitHub={!!gitHubToken}
|
||||
githubData={projectMenuCardData}
|
||||
<ConversationCard
|
||||
variant="compact"
|
||||
onDownloadWorkspace={handleDownloadWorkspace}
|
||||
title={conversation?.title ?? ""}
|
||||
lastUpdatedAt={conversation?.created_at ?? ""}
|
||||
selectedRepository={conversation?.selected_repository ?? null}
|
||||
status={conversation?.status}
|
||||
/>
|
||||
|
||||
<DownloadModal
|
||||
initialPath=""
|
||||
onClose={() => setDownloading(false)}
|
||||
isOpen={downloading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,10 +25,14 @@ export function ConfirmDeleteModal({
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
onClick={onConfirm}
|
||||
className="bg-[#4465DB]"
|
||||
className="bg-danger font-bold"
|
||||
text="Confirm"
|
||||
/>
|
||||
<ModalButton onClick={onCancel} className="bg-danger" text="Cancel" />
|
||||
<ModalButton
|
||||
onClick={onCancel}
|
||||
className="bg-neutral-500 font-bold"
|
||||
text="Cancel"
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { cn } from "#/utils/utils";
|
||||
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;
|
||||
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
position?: "top" | "bottom";
|
||||
}
|
||||
|
||||
export function ConversationCardContextMenu({
|
||||
onClose,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDownload,
|
||||
position = "bottom",
|
||||
}: ConversationCardContextMenuProps) {
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
testId="context-menu"
|
||||
className={cn(
|
||||
"right-0 absolute",
|
||||
position === "top" && "bottom-full",
|
||||
position === "bottom" && "top-full",
|
||||
)}
|
||||
>
|
||||
{onDelete && (
|
||||
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onEdit && (
|
||||
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
|
||||
Edit Title
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onDownload && (
|
||||
<ContextMenuListItem testId="download-button" onClick={onDownload}>
|
||||
Download Workspace
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -2,101 +2,156 @@ import React from "react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import {
|
||||
ProjectState,
|
||||
ProjectStatus,
|
||||
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";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ProjectCardProps {
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
onChangeTitle: (title: string) => void;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string; // ISO 8601
|
||||
state?: ProjectState;
|
||||
interface ConversationCardProps {
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
onChangeTitle?: (title: string) => void;
|
||||
onDownloadWorkspace?: () => void;
|
||||
isActive?: boolean;
|
||||
title: string;
|
||||
selectedRepository: string | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
status?: ProjectStatus;
|
||||
variant?: "compact" | "default";
|
||||
}
|
||||
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
onChangeTitle,
|
||||
name,
|
||||
repo,
|
||||
lastUpdated,
|
||||
state = "cold",
|
||||
}: ProjectCardProps) {
|
||||
onDownloadWorkspace,
|
||||
isActive,
|
||||
title,
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
status = "STOPPED",
|
||||
variant = "default",
|
||||
}: ConversationCardProps) {
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputRef.current?.value) {
|
||||
const trimmed = inputRef.current.value.trim();
|
||||
onChangeTitle(trimmed);
|
||||
onChangeTitle?.(trimmed);
|
||||
inputRef.current!.value = trimmed;
|
||||
} else {
|
||||
// reset the value if it's empty
|
||||
inputRef.current!.value = name;
|
||||
inputRef.current!.value = title;
|
||||
}
|
||||
|
||||
setTitleMode("view");
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputClick = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDelete = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
onDelete?.();
|
||||
};
|
||||
|
||||
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTitleMode("edit");
|
||||
setContextMenuVisible(false);
|
||||
};
|
||||
|
||||
const handleDownload = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onDownloadWorkspace?.();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (titleMode === "edit") {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [titleMode]);
|
||||
|
||||
const hasContextMenu = !!(onDelete || onChangeTitle || onDownloadWorkspace);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
onClick={onClick}
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600"
|
||||
className={cn(
|
||||
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
|
||||
variant === "compact" &&
|
||||
"h-auto w-fit rounded-xl border border-[#525252]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-testid="conversation-card-title"
|
||||
onClick={handleInputClick}
|
||||
onBlur={handleBlur}
|
||||
type="text"
|
||||
defaultValue={name}
|
||||
className="text-sm leading-6 font-semibold bg-transparent"
|
||||
/>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{isActive && <span className="w-2 h-2 bg-blue-500 rounded-full" />}
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<ConversationStateIndicator state={state} />
|
||||
<EllipsisButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
<ConversationStateIndicator status={status} />
|
||||
{hasContextMenu && (
|
||||
<EllipsisButton
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{contextMenuVisible && (
|
||||
<ContextMenu testId="context-menu" className="absolute left-full">
|
||||
<ContextMenuListItem
|
||||
testId="delete-button"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
onDelete={onDelete && handleDelete}
|
||||
onEdit={onChangeTitle && handleEdit}
|
||||
onDownload={onDownloadWorkspace && handleDownload}
|
||||
position={variant === "compact" ? "top" : "bottom"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repo && (
|
||||
<ConversationRepoLink
|
||||
repo={repo}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdated))} ago</time>
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
variant === "compact" && "flex items-center justify-between mt-1",
|
||||
)}
|
||||
>
|
||||
{selectedRepository && (
|
||||
<ConversationRepoLink
|
||||
selectedRepository={selectedRepository}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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 { useLocation, useNavigate, useParams } from "react-router";
|
||||
import { NavLink, 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;
|
||||
@@ -16,10 +16,8 @@ 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);
|
||||
@@ -60,26 +58,17 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
if (oldTitle !== newTitle)
|
||||
updateConversation({
|
||||
id: conversationId,
|
||||
conversation: { name: newTitle },
|
||||
conversation: { title: newTitle },
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickCard = (conversationId: string) => {
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="conversation-panel"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl overflow-y-auto"
|
||||
>
|
||||
<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,18 +82,25 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
{conversations?.map((project) => (
|
||||
<ConversationCard
|
||||
<NavLink
|
||||
key={project.conversation_id}
|
||||
onClick={() => handleClickCard(project.conversation_id)}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.name, title)
|
||||
}
|
||||
name={project.name}
|
||||
repo={project.repo}
|
||||
lastUpdated={project.lastUpdated}
|
||||
state={project.state}
|
||||
/>
|
||||
to={`/conversations/${project.conversation_id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<ConversationCard
|
||||
isActive={isActive}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.title, title)
|
||||
}
|
||||
title={project.title}
|
||||
selectedRepository={project.selected_repository}
|
||||
lastUpdatedAt={project.last_updated_at}
|
||||
status={project.status}
|
||||
/>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{confirmDeleteModalVisible && (
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
interface ConversationRepoLinkProps {
|
||||
repo: string;
|
||||
selectedRepository: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
export function ConversationRepoLink({
|
||||
repo,
|
||||
selectedRepository,
|
||||
onClick,
|
||||
}: ConversationRepoLinkProps) {
|
||||
return (
|
||||
<a
|
||||
data-testid="conversation-card-repo"
|
||||
href={`https://github.com/${repo}`}
|
||||
data-testid="conversation-card-selected-repository"
|
||||
href={`https://github.com/${selectedRepository}`}
|
||||
target="_blank noopener noreferrer"
|
||||
onClick={onClick}
|
||||
className="text-xs text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{repo}
|
||||
{selectedRepository}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
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 ProjectState =
|
||||
| "cold"
|
||||
| "cooling"
|
||||
| "finished"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "warm";
|
||||
export type ProjectStatus = "RUNNING" | "STOPPED";
|
||||
|
||||
const INDICATORS: Record<ProjectState, SVGIcon> = {
|
||||
cold: ColdIcon,
|
||||
cooling: CoolingIcon,
|
||||
finished: FinishedIcon,
|
||||
running: RunningIcon,
|
||||
waiting: WaitingIcon,
|
||||
warm: WarmIcon,
|
||||
const INDICATORS: Record<ProjectStatus, SVGIcon> = {
|
||||
STOPPED: ColdIcon,
|
||||
RUNNING: RunningIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
state: ProjectState;
|
||||
status: ProjectStatus;
|
||||
}
|
||||
|
||||
export function ConversationStateIndicator({
|
||||
state,
|
||||
status,
|
||||
}: ConversationStateIndicatorProps) {
|
||||
const StateIcon = INDICATORS[state];
|
||||
const StateIcon = INDICATORS[status];
|
||||
|
||||
return (
|
||||
<div data-testid={`${state}-indicator`}>
|
||||
<div data-testid={`${status}-indicator`}>
|
||||
<StateIcon />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
<GitHubRepositorySelector
|
||||
onInputChange={setSearchQuery}
|
||||
onSelect={handleSubmit}
|
||||
publicRepositories={searchedRepos}
|
||||
publicRepositories={searchedRepos || []}
|
||||
userRepositories={repositories}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EllipsisH from "#/icons/ellipsis-h.svg?react";
|
||||
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
|
||||
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
|
||||
import { ProjectMenuDetails } from "./project-menu-details";
|
||||
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { DownloadModal } from "#/components/shared/download-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuCardProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
githubData: {
|
||||
avatar: string | null;
|
||||
repoName: string;
|
||||
lastCommit: GitHubCommit;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function ProjectMenuCard({
|
||||
isConnectedToGitHub,
|
||||
githubData,
|
||||
}: ProjectMenuCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [downloading, setDownloading] = React.useState(false);
|
||||
|
||||
const toggleMenuVisibility = () => {
|
||||
setContextMenuIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleDownloadWorkspace = () => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
setDownloading(true);
|
||||
};
|
||||
|
||||
const handleDownloadClose = () => {
|
||||
setDownloading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
|
||||
{!downloading && contextMenuIsOpen && (
|
||||
<ProjectMenuCardContextMenu
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
onDownloadWorkspace={handleDownloadWorkspace}
|
||||
onClose={() => setContextMenuIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{githubData && (
|
||||
<ProjectMenuDetails
|
||||
repoName={githubData.repoName}
|
||||
avatar={githubData.avatar}
|
||||
lastCommit={githubData.lastCommit}
|
||||
/>
|
||||
)}
|
||||
{!githubData && (
|
||||
<ProjectMenuDetailsPlaceholder
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<DownloadModal
|
||||
initialPath=""
|
||||
onClose={handleDownloadClose}
|
||||
isOpen={downloading}
|
||||
/>
|
||||
{!downloading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMenuVisibility}
|
||||
aria-label={t(I18nKey.PROJECT_MENU_CARD$OPEN)}
|
||||
>
|
||||
<EllipsisH width={36} height={36} />
|
||||
</button>
|
||||
)}
|
||||
{connectToGitHubModalOpen && (
|
||||
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
|
||||
<ConnectToGitHubModal
|
||||
onClose={() => setConnectToGitHubModalOpen(false)}
|
||||
/>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import CloudConnection from "#/icons/cloud-connection.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsPlaceholderProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
onConnectToGitHub: () => void;
|
||||
}
|
||||
|
||||
export function ProjectMenuDetailsPlaceholder({
|
||||
isConnectedToGitHub,
|
||||
onConnectToGitHub,
|
||||
}: ProjectMenuDetailsPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm leading-6 font-semibold">
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnectToGitHub}
|
||||
disabled={isConnectedToGitHub}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs leading-4 text-[#A3A3A3] flex items-center gap-2",
|
||||
"hover:underline hover:underline-offset-2",
|
||||
)}
|
||||
>
|
||||
{!isConnectedToGitHub
|
||||
? t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB)
|
||||
: t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED)}
|
||||
<CloudConnection width={12} height={12} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ExternalLinkIcon from "#/icons/external-link.svg?react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsProps {
|
||||
repoName: string;
|
||||
avatar: string | null;
|
||||
lastCommit: GitHubCommit;
|
||||
}
|
||||
|
||||
export function ProjectMenuDetails({
|
||||
repoName,
|
||||
avatar,
|
||||
lastCommit,
|
||||
}: ProjectMenuDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
href={`https://github.com/${repoName}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{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}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-xs text-[#A3A3A3] hover:underline hover:underline-offset-2"
|
||||
>
|
||||
<span>{lastCommit.sha.slice(-7)}</span> <span>·</span>{" "}
|
||||
<span>
|
||||
{formatTimeDelta(new Date(lastCommit.commit.author.date))}{" "}
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS$AGO_LABEL)}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuCardContextMenuProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
onConnectToGitHub: () => void;
|
||||
onDownloadWorkspace: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProjectMenuCardContextMenu({
|
||||
isConnectedToGitHub,
|
||||
onConnectToGitHub,
|
||||
onDownloadWorkspace,
|
||||
onClose,
|
||||
}: ProjectMenuCardContextMenuProps) {
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
className="absolute right-0 bottom-[calc(100%+8px)]"
|
||||
>
|
||||
{!isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onConnectToGitHub}>
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
<ContextMenuListItem onClick={onDownloadWorkspace}>
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/features/served-host/path-form.tsx
Normal file
19
frontend/src/components/features/served-host/path-form.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface PathFormProps {
|
||||
ref: React.RefObject<HTMLFormElement | null>;
|
||||
onBlur: () => void;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
export function PathForm({ ref, onBlur, defaultValue }: PathFormProps) {
|
||||
return (
|
||||
<form ref={ref} onSubmit={(e) => e.preventDefault()} className="flex-1">
|
||||
<input
|
||||
name="url"
|
||||
type="text"
|
||||
defaultValue={defaultValue}
|
||||
className="w-full bg-transparent"
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import FolderIcon from "#/icons/docs.svg?react";
|
||||
import { FaListUl } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
@@ -11,30 +11,37 @@ 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 { useCurrentSettings } from "#/context/settings-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
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";
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
const user = useGitHubUser();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { logout } = useAuth();
|
||||
const { data: settings, isError: settingsIsError } = useSettings();
|
||||
const { isUpToDate: settingsAreUpToDate } = useSettingsUpToDate();
|
||||
const {
|
||||
data: settings,
|
||||
isError: settingsIsError,
|
||||
isSuccess: settingsSuccessfulyFetched,
|
||||
} = useSettings();
|
||||
|
||||
const { isUpToDate: settingsAreUpToDate } = useCurrentSettings();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
|
||||
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] =
|
||||
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
|
||||
@@ -43,6 +50,11 @@ 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
|
||||
@@ -51,22 +63,30 @@ 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 relative">
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
|
||||
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
|
||||
<div className="w-[34px] h-[34px] flex items-center justify-center">
|
||||
<AllHandsLogoButton onClick={handleClickLogo} />
|
||||
<div className="w-[34px] h-[34px] flex items-center justify-center mb-7">
|
||||
<AllHandsLogoButton onClick={handleEndSession} />
|
||||
</div>
|
||||
{user.isLoading && <LoadingSpinner size="small" />}
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
{MULTI_CONVERSATION_UI && (
|
||||
<TooltipButton
|
||||
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={
|
||||
@@ -76,33 +96,14 @@ 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 && (
|
||||
<div
|
||||
className="absolute h-full left-[calc(100%+12px)] top-0 z-20" // 12px padding (sidebar parent)
|
||||
>
|
||||
<ConversationPanelWrapper isOpen={conversationPanelIsOpen}>
|
||||
<ConversationPanel
|
||||
onClose={() => setConversationPanelIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</ConversationPanelWrapper>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -110,17 +111,12 @@ export function Sidebar() {
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{settingsIsError ||
|
||||
(showSettingsModal && (
|
||||
(showSettingsModal && settingsSuccessfulyFetched && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
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,10 +11,11 @@ interface UserAvatarProps {
|
||||
}
|
||||
|
||||
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
const buttonContent = (
|
||||
<button
|
||||
data-testid="user-avatar"
|
||||
type="button"
|
||||
return (
|
||||
<TooltipButton
|
||||
testId="user-avatar"
|
||||
tooltip="Account settings"
|
||||
ariaLabel="Account settings"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2 border-gray-200",
|
||||
@@ -30,12 +31,6 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content="Account settings" closeDelay={100}>
|
||||
{buttonContent}
|
||||
</Tooltip>
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
15
frontend/src/components/layout/served-app-label.tsx
Normal file
15
frontend/src/components/layout/served-app-label.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useActiveHost } from "#/hooks/query/use-active-host";
|
||||
|
||||
export function ServedAppLabel() {
|
||||
const { activeHost } = useActiveHost();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">App</div>
|
||||
<span className="border rounded-md text- px-1 font-bold">BETA</span>
|
||||
</div>
|
||||
{activeHost && <div className="w-2 h-2 bg-green-500 rounded-full" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
|
||||
ariaLabel="All Hands Logo"
|
||||
onClick={onClick}
|
||||
>
|
||||
<AllHandsLogo width={34} height={23} />
|
||||
<AllHandsLogo width={44} height={30} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
|
||||
onClick={onClick}
|
||||
testId="new-project-button"
|
||||
>
|
||||
<NewProjectIcon width={28} height={28} />
|
||||
<NewProjectIcon width={26} height={26} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CogTooth from "#/assets/cog-tooth";
|
||||
import { FaCog } from "react-icons/fa";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
interface SettingsButtonProps {
|
||||
@@ -13,7 +13,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
ariaLabel="Settings"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CogTooth />
|
||||
<FaCog size={24} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import { ReactNode } from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface TooltipButtonProps {
|
||||
children: ReactNode;
|
||||
@@ -8,6 +9,7 @@ interface TooltipButtonProps {
|
||||
href?: string;
|
||||
ariaLabel: string;
|
||||
testId?: string;
|
||||
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
|
||||
}
|
||||
|
||||
export function TooltipButton({
|
||||
@@ -17,6 +19,7 @@ export function TooltipButton({
|
||||
href,
|
||||
ariaLabel,
|
||||
testId,
|
||||
className,
|
||||
}: TooltipButtonProps) {
|
||||
const buttonContent = (
|
||||
<button
|
||||
@@ -24,7 +27,7 @@ export function TooltipButton({
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
|
||||
className={cn("hover:opacity-80", className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -35,7 +38,7 @@ export function TooltipButton({
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
|
||||
className={cn("hover:opacity-80", className)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
@@ -45,7 +48,7 @@ export function TooltipButton({
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} closeDelay={100}>
|
||||
<Tooltip content={tooltip} closeDelay={100} placement="right">
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -18,9 +18,10 @@ export function AdvancedOptionSwitch({
|
||||
|
||||
return (
|
||||
<Switch
|
||||
data-testid="advanced-option-switch"
|
||||
isDisabled={isDisabled}
|
||||
name="use-advanced-options"
|
||||
isSelected={showAdvancedOptions}
|
||||
defaultSelected={showAdvancedOptions}
|
||||
onValueChange={setShowAdvancedOptions}
|
||||
classNames={{
|
||||
thumb: cn(
|
||||
|
||||
@@ -22,7 +22,9 @@ export function CustomModelInput({
|
||||
{t(I18nKey.SETTINGS_FORM$CUSTOM_MODEL_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
data-testid="custom-model-input"
|
||||
isDisabled={isDisabled}
|
||||
isRequired
|
||||
id="custom-model"
|
||||
name="custom-model"
|
||||
defaultValue={defaultValue}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ModalButton } from "../../buttons/modal-button";
|
||||
import { CustomInput } from "../../custom-input";
|
||||
import { FormFieldset } from "../../form-fieldset";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
|
||||
interface AccountSettingsFormProps {
|
||||
onClose: () => void;
|
||||
@@ -30,10 +30,10 @@ export function AccountSettingsForm({
|
||||
}: AccountSettingsFormProps) {
|
||||
const { gitHubToken, setGitHubToken, logout } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { mutate: saveSettings } = useSaveSettings();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function AccountSettingsForm({
|
||||
({ label }) => label === language,
|
||||
)?.value;
|
||||
|
||||
if (languageKey) saveSettings({ LANGUAGE: languageKey });
|
||||
if (languageKey) await saveUserSettings({ LANGUAGE: languageKey });
|
||||
}
|
||||
|
||||
handleCaptureConsent(analytics);
|
||||
@@ -61,7 +61,7 @@ export function AccountSettingsForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
<ModalBody testID="account-settings-form">
|
||||
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<BaseModalTitle title="Account Settings" />
|
||||
@@ -137,6 +137,7 @@ export function AccountSettingsForm({
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
testId="save-settings"
|
||||
type="submit"
|
||||
intent="account"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<AccountSettingsForm
|
||||
onClose={onClose}
|
||||
selectedLanguage={settings.LANGUAGE}
|
||||
selectedLanguage={settings?.LANGUAGE || "en"}
|
||||
gitHubError={user.isError}
|
||||
analyticsConsent={analyticsConsent}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
data-testid="runtime-size"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,10 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
|
||||
import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
|
||||
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";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@@ -38,8 +41,9 @@ export function SettingsForm({
|
||||
securityAnalyzers,
|
||||
onClose,
|
||||
}: SettingsFormProps) {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const endSession = useEndSession();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
@@ -91,17 +95,21 @@ export function SettingsForm({
|
||||
const newSettings = extractSettings(formData);
|
||||
|
||||
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
|
||||
await saveSettings(newSettings, { onSuccess: onClose });
|
||||
await saveUserSettings(newSettings);
|
||||
onClose();
|
||||
resetOngoingSession();
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmResetSettings = async () => {
|
||||
await saveSettings(getDefaultSettings(), { onSuccess: onClose });
|
||||
await saveUserSettings(getDefaultSettings());
|
||||
onClose();
|
||||
resetOngoingSession();
|
||||
posthog.capture("settings_reset");
|
||||
};
|
||||
@@ -122,6 +130,8 @@ export function SettingsForm({
|
||||
}
|
||||
};
|
||||
|
||||
const isSaasMode = config?.APP_MODE === "saas";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
@@ -164,16 +174,21 @@ 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}
|
||||
@@ -191,6 +206,7 @@ export function SettingsForm({
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<ModalButton
|
||||
testId="save-settings-button"
|
||||
disabled={disabled}
|
||||
type="submit"
|
||||
text={t(I18nKey.SETTINGS_FORM$SAVE_LABEL)}
|
||||
|
||||
75
frontend/src/context/settings-context.tsx
Normal file
75
frontend/src/context/settings-context.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import {
|
||||
LATEST_SETTINGS_VERSION,
|
||||
Settings,
|
||||
settingsAreUpToDate,
|
||||
} from "#/services/settings";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
interface SettingsContextType {
|
||||
isUpToDate: boolean;
|
||||
setIsUpToDate: (value: boolean) => void;
|
||||
saveUserSettings: (newSettings: Partial<Settings>) => Promise<void>;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
const SettingsContext = React.createContext<SettingsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface SettingsProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
const { data: userSettings } = useSettings();
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
|
||||
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
|
||||
|
||||
const saveUserSettings = async (newSettings: Partial<Settings>) => {
|
||||
const updatedSettings: Partial<Settings> = {
|
||||
...userSettings,
|
||||
...newSettings,
|
||||
};
|
||||
|
||||
if (updatedSettings.LLM_API_KEY === "SET") {
|
||||
delete updatedSettings.LLM_API_KEY;
|
||||
}
|
||||
|
||||
await saveSettings(updatedSettings, {
|
||||
onSuccess: () => {
|
||||
if (!isUpToDate) {
|
||||
localStorage.setItem(
|
||||
"SETTINGS_VERSION",
|
||||
LATEST_SETTINGS_VERSION.toString(),
|
||||
);
|
||||
setIsUpToDate(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
isUpToDate,
|
||||
setIsUpToDate,
|
||||
saveUserSettings,
|
||||
settings: userSettings,
|
||||
}),
|
||||
[isUpToDate, setIsUpToDate, saveUserSettings, userSettings],
|
||||
);
|
||||
|
||||
return <SettingsContext value={value}>{children}</SettingsContext>;
|
||||
}
|
||||
|
||||
export function useCurrentSettings() {
|
||||
const context = React.useContext(SettingsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useCurrentSettings must be used within a SettingsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from "react";
|
||||
import { settingsAreUpToDate } from "#/services/settings";
|
||||
|
||||
interface SettingsUpToDateContextType {
|
||||
isUpToDate: boolean;
|
||||
setIsUpToDate: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const SettingsUpToDateContext = React.createContext<
|
||||
SettingsUpToDateContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
interface SettingsUpToDateProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsUpToDateProvider({
|
||||
children,
|
||||
}: SettingsUpToDateProviderProps) {
|
||||
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ isUpToDate, setIsUpToDate }),
|
||||
[isUpToDate, setIsUpToDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsUpToDateContext value={value}>{children}</SettingsUpToDateContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSettingsUpToDate() {
|
||||
const context = React.useContext(SettingsUpToDateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useSettingsUpToDate must be used within a SettingsUpToDateProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -2,12 +2,18 @@ import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import {
|
||||
handleAssistantMessage,
|
||||
handleStatusMessage,
|
||||
} from "#/services/actions";
|
||||
import { useRate } from "#/hooks/use-rate";
|
||||
import { OpenHandsParsedEvent } from "#/types/core";
|
||||
import { AgentStateChangeObservation } from "#/types/core/observations";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
|
||||
const isOpenHandsMessage = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"id" in event &&
|
||||
@@ -15,10 +21,26 @@ const isOpenHandsMessage = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
"message" in event &&
|
||||
"timestamp" in event;
|
||||
|
||||
const isAgentStateChangeObservation = (
|
||||
const isUserMessage = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is AgentStateChangeObservation =>
|
||||
"observation" in event && event.observation === "agent_state_changed";
|
||||
): 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);
|
||||
|
||||
export enum WsClientProviderStatus {
|
||||
CONNECTED,
|
||||
@@ -43,16 +65,45 @@ const WsClientContext = React.createContext<UseWsClient>({
|
||||
|
||||
interface WsClientProviderProps {
|
||||
conversationId: string;
|
||||
ghToken: string | null;
|
||||
}
|
||||
|
||||
interface ErrorArg {
|
||||
message?: string;
|
||||
data?: ErrorArgData | unknown;
|
||||
}
|
||||
|
||||
interface ErrorArgData {
|
||||
msg_id: string;
|
||||
}
|
||||
|
||||
export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
|
||||
const isObject = (val: unknown): val is object =>
|
||||
!!val && typeof val === "object";
|
||||
const isString = (val: unknown): val is string => typeof val === "string";
|
||||
if (isObject(data) && "message" in data && isString(data.message)) {
|
||||
let msgId: string | undefined;
|
||||
if (
|
||||
"data" in data &&
|
||||
isObject(data.data) &&
|
||||
"msg_id" in data.data &&
|
||||
isString(data.data.msg_id)
|
||||
) {
|
||||
msgId = data.data.msg_id;
|
||||
}
|
||||
handleStatusMessage({
|
||||
type: "error",
|
||||
message: data.message,
|
||||
id: msgId,
|
||||
status_update: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -74,7 +125,7 @@ export function WsClientProvider({
|
||||
}
|
||||
|
||||
function handleMessage(event: Record<string, unknown>) {
|
||||
if (isOpenHandsMessage(event) && !isAgentStateChangeObservation(event)) {
|
||||
if (isOpenHandsEvent(event) && isMessageAction(event)) {
|
||||
messageRateHandler.record(new Date().getTime());
|
||||
}
|
||||
setEvents((prevEvents) => [...prevEvents, event]);
|
||||
@@ -85,7 +136,7 @@ export function WsClientProvider({
|
||||
handleAssistantMessage(event);
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
function handleDisconnect(data: unknown) {
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
const sio = sioRef.current;
|
||||
if (!sio) {
|
||||
@@ -93,13 +144,19 @@ 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() {
|
||||
posthog.capture("socket_error");
|
||||
function handleError(data: unknown) {
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
posthog.capture("socket_error");
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
lastEventRef.current = null;
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
@@ -118,9 +175,6 @@ export function WsClientProvider({
|
||||
|
||||
sio = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
auth: {
|
||||
github_token: ghToken || undefined,
|
||||
},
|
||||
query,
|
||||
});
|
||||
sio.on("connect", handleConnect);
|
||||
@@ -130,7 +184,6 @@ export function WsClientProvider({
|
||||
sio.on("disconnect", handleDisconnect);
|
||||
|
||||
sioRef.current = sio;
|
||||
ghTokenRef.current = ghToken;
|
||||
|
||||
return () => {
|
||||
sio.off("connect", handleConnect);
|
||||
@@ -139,7 +192,7 @@ export function WsClientProvider({
|
||||
sio.off("connect_failed", handleError);
|
||||
sio.off("disconnect", handleDisconnect);
|
||||
};
|
||||
}, [ghToken, conversationId]);
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import toast from "react-hot-toast";
|
||||
import store from "./store";
|
||||
import { useConfig } from "./hooks/query/use-config";
|
||||
import { AuthProvider } from "./context/auth-context";
|
||||
import { SettingsUpToDateProvider } from "./context/settings-up-to-date-context";
|
||||
import { SettingsProvider } from "./context/settings-context";
|
||||
|
||||
function PosthogInit() {
|
||||
const { data: config } = useConfig();
|
||||
@@ -50,13 +50,20 @@ async function prepareApp() {
|
||||
}
|
||||
}
|
||||
|
||||
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error, query) => {
|
||||
if (!query.queryKey.includes("authenticated")) toast.error(error.message);
|
||||
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
},
|
||||
mutations: {
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
@@ -72,12 +79,12 @@ prepareApp().then(() =>
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<SettingsUpToDateProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SettingsProvider>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
</QueryClientProvider>
|
||||
</SettingsUpToDateProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -13,13 +13,18 @@ export const useCreateConversation = () => {
|
||||
const { gitHubToken } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedRepository, files } = useSelector(
|
||||
const { selectedRepository, files, importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: { q?: string }) => {
|
||||
if (!variables.q?.trim() && !selectedRepository && files.length === 0) {
|
||||
if (
|
||||
!variables.q?.trim() &&
|
||||
!selectedRepository &&
|
||||
files.length === 0 &&
|
||||
!importedProjectZip
|
||||
) {
|
||||
throw new Error("No query provided");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
LATEST_SETTINGS_VERSION,
|
||||
Settings,
|
||||
} from "#/services/settings";
|
||||
import { ApiSettings, DEFAULT_SETTINGS, Settings } from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
|
||||
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
|
||||
const apiSettings: Partial<ApiSettings> = {
|
||||
@@ -24,19 +18,11 @@ const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
|
||||
|
||||
export const useSaveSettings = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { isUpToDate, setIsUpToDate } = useSettingsUpToDate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: saveSettingsMutationFn,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
if (!isUpToDate) {
|
||||
localStorage.setItem(
|
||||
"SETTINGS_VERSION",
|
||||
LATEST_SETTINGS_VERSION.toString(),
|
||||
);
|
||||
setIsUpToDate(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
16
frontend/src/hooks/mutation/use-upload-files.ts
Normal file
16
frontend/src/hooks/mutation/use-upload-files.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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),
|
||||
});
|
||||
};
|
||||
51
frontend/src/hooks/query/use-active-host.ts
Normal file
51
frontend/src/hooks/query/use-active-host.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { RootState } from "#/store";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
export const useActiveHost = () => {
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const [activeHost, setActiveHost] = React.useState<string | null>(null);
|
||||
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: [conversationId, "hosts"],
|
||||
queryFn: async () => {
|
||||
const response = await openHands.get<{ hosts: string[] }>(
|
||||
`/api/conversations/${conversationId}/web-hosts`,
|
||||
);
|
||||
return { hosts: Object.keys(response.data.hosts) };
|
||||
},
|
||||
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
|
||||
initialData: { hosts: [] },
|
||||
});
|
||||
|
||||
const apps = useQueries({
|
||||
queries: data.hosts.map((host) => ({
|
||||
queryKey: [conversationId, "hosts", host],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
await axios.get(host);
|
||||
return host;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
})),
|
||||
});
|
||||
|
||||
const appsData = apps.map((app) => app.data);
|
||||
|
||||
React.useEffect(() => {
|
||||
const successfulApp = appsData.find((app) => app);
|
||||
setActiveHost(successfulApp || "");
|
||||
}, [appsData]);
|
||||
|
||||
return { activeHost };
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
import { retrieveGitHubAppInstallations } from "#/api/github";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useAppInstallations = () => {
|
||||
const { data: config } = useConfig();
|
||||
@@ -9,10 +9,7 @@ export const useAppInstallations = () => {
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", gitHubToken, config?.GITHUB_CLIENT_ID],
|
||||
queryFn: async () => {
|
||||
const data = await retrieveGitHubAppInstallations();
|
||||
return data;
|
||||
},
|
||||
queryFn: OpenHands.getGitHubUserInstallationIds,
|
||||
enabled:
|
||||
!!gitHubToken &&
|
||||
!!config?.GITHUB_CLIENT_ID &&
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { retrieveGitHubUser } from "#/api/github";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useGitHubUser = () => {
|
||||
const { gitHubToken, setUserId } = useAuth();
|
||||
@@ -11,7 +11,7 @@ export const useGitHubUser = () => {
|
||||
|
||||
const user = useQuery({
|
||||
queryKey: ["user", gitHubToken],
|
||||
queryFn: retrieveGitHubUser,
|
||||
queryFn: OpenHands.getGitHubUser,
|
||||
enabled: !!gitHubToken && !!config?.APP_MODE,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { retrieveLatestGitHubCommit } from "#/api/github";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
interface UseLatestRepoCommitConfig {
|
||||
repository: string | null;
|
||||
}
|
||||
|
||||
export const useLatestRepoCommit = (config: UseLatestRepoCommitConfig) => {
|
||||
const { gitHubToken } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["latest_commit", gitHubToken, config.repository],
|
||||
queryFn: () => retrieveLatestGitHubCommit(config.repository!),
|
||||
enabled: !!gitHubToken && !!config.repository,
|
||||
});
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { searchPublicRepositories } from "#/api/github";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export function useSearchRepositories(query: string) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", query],
|
||||
queryFn: () => searchPublicRepositories(query, 3),
|
||||
queryFn: () => OpenHands.searchGitHubRepositories(query, 3),
|
||||
enabled: !!query,
|
||||
select: (data) => data.map((repo) => ({ ...repo, is_public: true })),
|
||||
initialData: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +39,6 @@ export const useSettings = () => {
|
||||
const query = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettingsQueryFn,
|
||||
initialData: DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
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_CONVO_UI_IS_ENABLED && !!cid,
|
||||
enabled: !!cid,
|
||||
retry: false,
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
// Sometimes we ship major changes, like a new default agent.
|
||||
|
||||
import React from "react";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
getCurrentSettingsVersion,
|
||||
DEFAULT_SETTINGS,
|
||||
getLocalStorageSettings,
|
||||
} from "#/services/settings";
|
||||
import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
@@ -12,7 +12,7 @@ import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
// In this case, we may want to override a previous choice made by the user.
|
||||
export const useMaybeMigrateSettings = () => {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const { isUpToDate } = useSettingsUpToDate();
|
||||
const { isUpToDate } = useCurrentSettings();
|
||||
|
||||
const maybeMigrateSettings = async () => {
|
||||
const currentVersion = getCurrentSettingsVersion();
|
||||
|
||||
@@ -131,7 +131,9 @@ export const useTerminal = ({
|
||||
content = content.replaceAll(secret, "*".repeat(10));
|
||||
});
|
||||
|
||||
terminal.current?.writeln(parseTerminalOutput(content));
|
||||
terminal.current?.writeln(
|
||||
parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()),
|
||||
);
|
||||
|
||||
if (type === "output") {
|
||||
terminal.current.write(`\n$ `);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user