mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9442451dc9 | |||
| 2af63bea63 | |||
| c3552a765d | |||
| 939feb1b49 | |||
| b7a7d78361 | |||
| 63b759540c | |||
| 33640ed157 | |||
| bc3f0ac24a | |||
| f55ddbed0e | |||
| fd81670ba8 | |||
| 79ed4e3567 | |||
| b3fbbbaa9d | |||
| 87c02177d7 | |||
| 207df9dd30 | |||
| 59f7093428 | |||
| 123fb4b75d | |||
| 40e2d28e87 | |||
| c555611d58 | |||
| 50e7da9c3d | |||
| 0cfb132ab7 | |||
| 17f4c6e1a9 | |||
| 910b283ac2 | |||
| b54724ac3f | |||
| 0633a99298 | |||
| d9c5f11046 | |||
| 32fdcd58e5 | |||
| de71b7cdb8 | |||
| 04aeccfb69 | |||
| 4eea1286d4 | |||
| 488a320ffd | |||
| 377fadc2eb | |||
| 7df7f43e3c | |||
| a45aba512a | |||
| a1a9d2f175 | |||
| 79492b6551 | |||
| 80fdb9a2f4 | |||
| 975e75531d | |||
| 1b5f5bcdad | |||
| 8c00d96024 | |||
| bf8ccc8fc3 | |||
| 037d770f66 | |||
| dd50246672 | |||
| 090771674c | |||
| d8ab0208ba | |||
| a07e8272da | |||
| be82832eb1 | |||
| 67c8915d51 | |||
| 40b3ccb17c | |||
| 35c68863dc | |||
| 8bfee87bcf | |||
| e1383afbc3 | |||
| 4ce3b9094a | |||
| 0a4e196670 | |||
| 8d32a59f55 | |||
| 38b92f4251 | |||
| 88dbe85594 | |||
| f5003a7449 | |||
| a6810fa6ad | |||
| fc05d8d4eb | |||
| 1d6ef0e18e | |||
| dc0e223d1a | |||
| 932de79154 | |||
| fa625fed70 | |||
| f9fa1d95cb | |||
| 5615d54f81 | |||
| 8166bf768a | |||
| c3991c870d | |||
| 1a27619b39 | |||
| cc15aee405 | |||
| 53390d9885 | |||
| 0335b1a634 | |||
| bb362cd377 | |||
| 4405b109e3 | |||
| 47464a9cfa | |||
| 2b3fd94540 | |||
| 1bd46f3832 | |||
| 8a063fdf6a | |||
| 025dac5d8f | |||
| 0e5e754420 | |||
| 7a8e207985 | |||
| a4de0f2142 | |||
| 27716171bf | |||
| e5d7735d75 | |||
| 83ccb74d36 | |||
| 118957235d | |||
| 4a6406ed71 | |||
| 4bef974a89 | |||
| e497438085 | |||
| 74b3335b7d | |||
| 55c41212c8 | |||
| 4374ea08d3 | |||
| 436ecb80a3 | |||
| df9e9fca5a | |||
| add0e7d05c | |||
| 145194c87b | |||
| 6eafe0d2a8 | |||
| eeb2342509 | |||
| edfba4618a | |||
| 98751a3ee2 |
@@ -31,6 +31,8 @@ body:
|
||||
options:
|
||||
- Docker command in README
|
||||
- Development workflow
|
||||
- app.all-hands.dev
|
||||
- Other
|
||||
default: 0
|
||||
|
||||
- type: input
|
||||
|
||||
@@ -286,7 +286,6 @@ jobs:
|
||||
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
|
||||
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
SKIP_CONTAINER_LOGS=true \
|
||||
TEST_RUNTIME=eventstream \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
@@ -364,7 +363,6 @@ jobs:
|
||||
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
|
||||
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
SKIP_CONTAINER_LOGS=true \
|
||||
TEST_RUNTIME=eventstream \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
@@ -424,9 +422,9 @@ jobs:
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
|
||||
--name openhands-app-$SHORT_SHA \
|
||||
ghcr.io/all-hands-ai/runtime:$SHORT_SHA"
|
||||
docker.all-hands.dev/all-hands-ai/openhands:$SHORT_SHA"
|
||||
|
||||
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body)
|
||||
|
||||
|
||||
@@ -11,5 +11,5 @@ jobs:
|
||||
uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main
|
||||
if: github.event.label.name == 'fix-me'
|
||||
with:
|
||||
issue_number: ${{ github.event.issue.number }}
|
||||
max_iterations: 50
|
||||
secrets: inherit
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
### 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. Follow these steps:
|
||||
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.9-nikolaik
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -38,15 +38,16 @@ 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.12-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.9-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -32,7 +32,8 @@ workspace_base = "./workspace"
|
||||
# Enable saving and restoring the session when run from CLI
|
||||
#enable_cli_session = false
|
||||
|
||||
# Path to store trajectories
|
||||
# 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"
|
||||
|
||||
# File store path
|
||||
|
||||
@@ -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.9-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+2
-2
@@ -161,7 +161,7 @@ Pour créer un workflow d'évaluation pour votre benchmark, suivez ces étapes :
|
||||
instruction=instruction,
|
||||
test_result=evaluation_result,
|
||||
metadata=metadata,
|
||||
history=state.history.compatibility_for_eval_history_pairs(),
|
||||
history=compatibility_for_eval_history_pairs(state.history),
|
||||
metrics=state.metrics.get() if state.metrics else None,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
)
|
||||
@@ -260,7 +260,7 @@ def codeact_user_response(state: State | None) -> str:
|
||||
# vérifier si l'agent a essayé de parler à l'utilisateur 3 fois, si oui, faire savoir à l'agent qu'il peut abandonner
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) >= 2:
|
||||
|
||||
+2
-2
@@ -158,7 +158,7 @@ OpenHands 的主要入口点在 `openhands/core/main.py` 中。以下是它工
|
||||
instruction=instruction,
|
||||
test_result=evaluation_result,
|
||||
metadata=metadata,
|
||||
history=state.history.compatibility_for_eval_history_pairs(),
|
||||
history=compatibility_for_eval_history_pairs(state.history),
|
||||
metrics=state.metrics.get() if state.metrics else None,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
)
|
||||
@@ -257,7 +257,7 @@ def codeact_user_response(state: State | None) -> str:
|
||||
# 检查代理是否已尝试与用户对话 3 次,如果是,让代理知道它可以放弃
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) >= 2:
|
||||
|
||||
@@ -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.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-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.12 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ To create an evaluation workflow for your benchmark, follow these steps:
|
||||
instruction=instruction,
|
||||
test_result=evaluation_result,
|
||||
metadata=metadata,
|
||||
history=state.history.compatibility_for_eval_history_pairs(),
|
||||
history=compatibility_for_eval_history_pairs(state.history),
|
||||
metrics=state.metrics.get() if state.metrics else None,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
)
|
||||
@@ -257,7 +257,7 @@ def codeact_user_response(state: State | None) -> str:
|
||||
# check if the agent has tried to talk to the user 3 times, if so, let the agent know it can give up
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) >= 2:
|
||||
|
||||
@@ -19,6 +19,15 @@ OpenHands provides a user-friendly Graphical User Interface (GUI) mode for inter
|
||||
3. Enter the corresponding `API Key` for your chosen provider.
|
||||
4. Click "Save" to apply the settings.
|
||||
|
||||
### GitHub Token Setup
|
||||
|
||||
OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if it is available. This can happen in two ways:
|
||||
|
||||
1. Locally (OSS): The user directly inputs their GitHub token.
|
||||
2. Online (SaaS): The token is obtained through GitHub OAuth authentication.
|
||||
|
||||
When you reach the `/app` route, the app checks if a token is present. If it finds one, it sets it in the environment for the agent to use.
|
||||
|
||||
### Advanced Settings
|
||||
|
||||
1. Toggle `Advanced Options` to access additional settings.
|
||||
|
||||
@@ -44,15 +44,16 @@ 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.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
-e LLM_MODEL=$LLM_MODEL \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v $WORKSPACE_BASE:/opt/workspace_base \
|
||||
-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.12 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
@@ -11,15 +11,16 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
```
|
||||
|
||||
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# LiteLLM Proxy
|
||||
|
||||
OpenHands supports using the [LiteLLM proxy](https://docs.litellm.ai/docs/proxy/quick_start) to access various LLM providers.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use LiteLLM proxy with OpenHands, you need to:
|
||||
|
||||
1. Set up a LiteLLM proxy server (see [LiteLLM documentation](https://docs.litellm.ai/docs/proxy/quick_start))
|
||||
2. When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
|
||||
* Enable `Advanced Options`
|
||||
* `Custom Model` to the prefix `litellm_proxy/` + the model you will be using (e.g. `litellm_proxy/anthropic.claude-3-5-sonnet-20241022-v2:0`)
|
||||
* `Base URL` to your LiteLLM proxy URL (e.g. `https://your-litellm-proxy.com`)
|
||||
* `API Key` to your LiteLLM proxy API key
|
||||
|
||||
## Supported Models
|
||||
|
||||
The supported models depend on your LiteLLM proxy configuration. OpenHands supports any model that your LiteLLM proxy is configured to handle.
|
||||
|
||||
Refer to your LiteLLM proxy configuration for the list of available models and their names.
|
||||
@@ -4,11 +4,11 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
|
||||
|
||||
## Model Recommendations
|
||||
|
||||
Based on a recent evaluation of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. The full analysis can be found in [this blog article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed).
|
||||
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 27% resolve rate with the default agent in OpenHands.
|
||||
- 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.
|
||||
|
||||
@@ -63,6 +63,7 @@ We have a few guides for running OpenHands with specific model providers:
|
||||
- [Azure](llms/azure-llms)
|
||||
- [Google](llms/google-llms)
|
||||
- [Groq](llms/groq)
|
||||
- [LiteLLM Proxy](llms/litellm-proxy)
|
||||
- [OpenAI](llms/openai-llms)
|
||||
- [OpenRouter](llms/openrouter)
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ docker run # ...
|
||||
-e RUNTIME=remote \
|
||||
-e SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.app.all-hands.dev" \
|
||||
-e SANDBOX_API_KEY="your-all-hands-api-key" \
|
||||
-e SANDBOX_KEEP_REMOTE_RUNTIME_ALIVE="true" \
|
||||
-e SANDBOX_KEEP_RUNTIME_ALIVE="true" \
|
||||
# ...
|
||||
```
|
||||
|
||||
|
||||
Generated
+1457
-1258
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -15,10 +15,10 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.5.2",
|
||||
"@docusaurus/plugin-content-pages": "^3.5.2",
|
||||
"@docusaurus/preset-classic": "^3.5.2",
|
||||
"@docusaurus/theme-mermaid": "^3.5.2",
|
||||
"@docusaurus/core": "^3.6.0",
|
||||
"@docusaurus/plugin-content-pages": "^3.6.0",
|
||||
"@docusaurus/preset-classic": "^3.6.0",
|
||||
"@docusaurus/theme-mermaid": "^3.6.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.5.2",
|
||||
"@docusaurus/tsconfig": "^3.6.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
|
||||
@@ -76,6 +76,11 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Groq',
|
||||
id: 'usage/llms/groq',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'LiteLLM Proxy',
|
||||
id: 'usage/llms/litellm-proxy',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'OpenAI',
|
||||
|
||||
+1813
-1594
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ from evaluation.EDA.game import Q20Game, Q20GameCelebrity
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -34,7 +35,8 @@ def codeact_user_response_eda(state: State) -> str:
|
||||
|
||||
# retrieve the latest model message from history
|
||||
if state.history:
|
||||
model_guess = state.history.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_guess = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
assert game is not None, 'Game is not initialized.'
|
||||
msg = game.generate_user_response(model_guess)
|
||||
@@ -139,7 +141,8 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
final_message = state.history.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
final_message = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}')
|
||||
test_result = game.reward()
|
||||
@@ -148,7 +151,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -16,6 +16,7 @@ from evaluation.agent_bench.helper import (
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -242,7 +243,7 @@ def process_instance(
|
||||
raw_ans = ''
|
||||
|
||||
# retrieve the last agent message or thought
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if event.source == 'agent':
|
||||
if isinstance(event, AgentFinishAction):
|
||||
raw_ans = event.thought
|
||||
@@ -271,7 +272,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from evaluation.aider_bench.helper import (
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -250,7 +251,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Save the output
|
||||
|
||||
@@ -13,6 +13,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -299,7 +300,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
test_result['generated'] = test_result['metadata']['1_copy_change_code']
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from tqdm import tqdm
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -46,7 +47,7 @@ def codeact_user_response(state: State) -> str:
|
||||
# check if the agent has tried to talk to the user 3 times, if so, let the agent know it can give up
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) > 2:
|
||||
@@ -431,7 +432,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -9,6 +9,7 @@ from datasets import load_dataset
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -89,7 +90,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# find the last delegate action
|
||||
last_delegate_action = None
|
||||
|
||||
@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -173,14 +174,14 @@ def initialize_runtime(runtime: Runtime, data_files: list[str]):
|
||||
|
||||
|
||||
def get_last_agent_finish_action(state: State) -> AgentFinishAction:
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if isinstance(event, AgentFinishAction):
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def get_last_message_action(state: State) -> MessageAction:
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if isinstance(event, MessageAction):
|
||||
return event
|
||||
return None
|
||||
@@ -307,7 +308,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# DiscoveryBench Evaluation
|
||||
eval_rec = run_eval_gold_vs_gen_NL_hypo_workflow(
|
||||
|
||||
@@ -12,6 +12,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -166,7 +167,7 @@ def process_instance(
|
||||
|
||||
model_answer_raw = ''
|
||||
# get the last message or thought from the agent
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if event.source == 'agent':
|
||||
if isinstance(event, AgentFinishAction):
|
||||
model_answer_raw = event.thought
|
||||
@@ -203,7 +204,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -101,7 +102,8 @@ def process_instance(
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# retrieve the last message from the agent
|
||||
model_answer_raw = state.history.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_answer_raw = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
# attempt to parse model_answer
|
||||
ast_eval_fn = instance['ast_eval']
|
||||
@@ -114,7 +116,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
output = EvalOutput(
|
||||
instance_id=instance_id,
|
||||
|
||||
@@ -28,6 +28,7 @@ from datasets import load_dataset
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -244,7 +245,7 @@ Ok now its time to start solving the question. Good luck!
|
||||
'C': False,
|
||||
'D': False,
|
||||
}
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if (
|
||||
isinstance(event, AgentFinishAction)
|
||||
and event.source != 'user'
|
||||
@@ -300,7 +301,7 @@ Ok now its time to start solving the question. Good luck!
|
||||
instance_id=str(instance.instance_id),
|
||||
instruction=instruction,
|
||||
metadata=metadata,
|
||||
history=state.history.compatibility_for_eval_history_pairs(),
|
||||
history=compatibility_for_eval_history_pairs(state.history),
|
||||
metrics=metrics,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
test_result={
|
||||
|
||||
@@ -21,6 +21,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -255,7 +256,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -129,7 +129,7 @@ def process_instance(
|
||||
# # result evaluation
|
||||
# # =============================================
|
||||
|
||||
histories = [event_to_dict(event) for event in state.history.get_events()]
|
||||
histories = [event_to_dict(event) for event in state.history]
|
||||
test_result: TestResult = test_class.verify_result(runtime, histories)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -225,7 +226,7 @@ def process_instance(
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
final_message = ''
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if isinstance(event, AgentFinishAction):
|
||||
final_message = event.thought
|
||||
break
|
||||
@@ -247,7 +248,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -65,7 +66,7 @@ def get_config(
|
||||
browsergym_eval_env=env_id,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
@@ -182,7 +183,7 @@ def process_instance(
|
||||
|
||||
# Instruction is the first message from the USER
|
||||
instruction = ''
|
||||
for event in state.history.get_events():
|
||||
for event in state.history:
|
||||
if isinstance(event, MessageAction):
|
||||
instruction = event.content
|
||||
break
|
||||
@@ -194,7 +195,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -13,6 +13,7 @@ from evaluation.mint.tasks import Task
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -28,6 +29,7 @@ from openhands.core.config import (
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
CmdRunAction,
|
||||
MessageAction,
|
||||
)
|
||||
@@ -45,7 +47,10 @@ def codeact_user_response_mint(state: State, task: Task, task_config: dict[str,
|
||||
task=task,
|
||||
task_config=task_config,
|
||||
)
|
||||
last_action = state.history.get_last_action()
|
||||
last_action = next(
|
||||
(event for event in reversed(state.history) if isinstance(event, Action)),
|
||||
None,
|
||||
)
|
||||
result_state: TaskState = env.step(last_action.message or '')
|
||||
|
||||
state.extra_data['task_state'] = result_state
|
||||
@@ -202,7 +207,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -24,6 +24,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -256,7 +257,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -71,7 +72,7 @@ def get_config(
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
@@ -232,7 +233,7 @@ If the program uses some packages that are incompatible, please figure out alter
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from functools import partial
|
||||
|
||||
import pandas as pd
|
||||
from swebench.harness.grading import get_eval_report
|
||||
@@ -83,6 +84,7 @@ def get_config(instance: pd.Series) -> AppConfig:
|
||||
timeout=1800,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
@@ -93,13 +95,28 @@ def get_config(instance: pd.Series) -> AppConfig:
|
||||
|
||||
def process_instance(
|
||||
instance: pd.Series,
|
||||
metadata: EvalMetadata | None = None,
|
||||
metadata: EvalMetadata,
|
||||
reset_logger: bool = True,
|
||||
log_dir: str | None = None,
|
||||
) -> EvalOutput:
|
||||
"""
|
||||
Evaluate agent performance on a SWE-bench problem instance.
|
||||
|
||||
Note that this signature differs from the expected input to `run_evaluation`. Use
|
||||
`functools.partial` to provide optional arguments before passing to the evaluation harness.
|
||||
|
||||
Args:
|
||||
log_dir (str | None, default=None): Path to directory where log files will be written. Must
|
||||
be provided if `reset_logger` is set.
|
||||
|
||||
Raises:
|
||||
AssertionError: if the `reset_logger` flag is set without a provided log directory.
|
||||
"""
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
global output_file
|
||||
log_dir = output_file.replace('.jsonl', '.logs')
|
||||
assert (
|
||||
log_dir is not None
|
||||
), "Can't reset logger without a provided log directory."
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
|
||||
else:
|
||||
@@ -126,6 +143,7 @@ def process_instance(
|
||||
return EvalOutput(
|
||||
instance_id=instance_id,
|
||||
test_result=instance['test_result'],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
runtime = create_runtime(config)
|
||||
@@ -175,6 +193,7 @@ def process_instance(
|
||||
return EvalOutput(
|
||||
instance_id=instance_id,
|
||||
test_result=instance['test_result'],
|
||||
metadata=metadata,
|
||||
)
|
||||
elif 'APPLY_PATCH_PASS' in apply_patch_output:
|
||||
logger.info(f'[{instance_id}] {APPLY_PATCH_PASS}:\n{apply_patch_output}')
|
||||
@@ -268,6 +287,7 @@ def process_instance(
|
||||
return EvalOutput(
|
||||
instance_id=instance_id,
|
||||
test_result=instance['test_result'],
|
||||
metadata=metadata,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
@@ -354,12 +374,26 @@ if __name__ == '__main__':
|
||||
output_file = args.input_file.replace('.jsonl', '.swebench_eval.jsonl')
|
||||
instances = prepare_dataset(predictions, output_file, args.eval_n_limit)
|
||||
|
||||
# If possible, load the relevant metadata to avoid issues with `run_evaluation`.
|
||||
metadata: EvalMetadata | None = None
|
||||
metadata_filepath = os.path.join(os.path.dirname(args.input_file), 'metadata.json')
|
||||
if os.path.exists(metadata_filepath):
|
||||
with open(metadata_filepath, 'r') as metadata_file:
|
||||
data = metadata_file.read()
|
||||
metadata = EvalMetadata.model_validate_json(data)
|
||||
|
||||
# The evaluation harness constrains the signature of `process_instance_func` but we need to
|
||||
# pass extra information. Build a new function object to avoid issues with multiprocessing.
|
||||
process_instance_func = partial(
|
||||
process_instance, log_dir=output_file.replace('.jsonl', '.logs')
|
||||
)
|
||||
|
||||
run_evaluation(
|
||||
instances,
|
||||
metadata=None,
|
||||
metadata=metadata,
|
||||
output_file=output_file,
|
||||
num_workers=args.eval_num_workers,
|
||||
process_instance_func=process_instance,
|
||||
process_instance_func=process_instance_func,
|
||||
)
|
||||
|
||||
# Load evaluated predictions & print number of resolved predictions
|
||||
|
||||
@@ -36,8 +36,8 @@ from openhands.events.action import CmdRunAction, MessageAction
|
||||
from openhands.events.observation import CmdOutputObservation, ErrorObservation
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.utils.shutdown_listener import sleep_if_should_continue
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
from openhands.utils.shutdown_listener import sleep_if_should_continue
|
||||
|
||||
USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
|
||||
USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'false').lower() == 'true'
|
||||
@@ -81,7 +81,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
|
||||
'</pr_description>\n\n'
|
||||
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <pr_description> are met?\n'
|
||||
"I've already taken care of all changes to any of the test files described in the <pr_description>. This means you DON'T have to modify the testing logic or any of the tests in any way!\n"
|
||||
'Your task is to make the minimal changes to non-tests files in the /repo directory to ensure the <pr_description> is satisfied.\n'
|
||||
'Your task is to make the minimal changes to non-tests files in the /workspace directory to ensure the <pr_description> is satisfied.\n'
|
||||
'Follow these steps to resolve the issue:\n'
|
||||
'1. As a first step, it might be a good idea to explore the repo to familiarize yourself with its structure.\n'
|
||||
'2. Create a script to reproduce the error and execute it with `python <filename.py>` using the BashTool, to confirm the error\n'
|
||||
@@ -145,7 +145,8 @@ def get_config(
|
||||
platform='linux/amd64',
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
@@ -443,7 +444,8 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
histories = [event_to_dict(event) for event in state.history.get_events()]
|
||||
# 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
|
||||
|
||||
# Save the output
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -126,7 +127,8 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# retrieve the last message from the agent
|
||||
model_answer_raw = state.history.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_answer_raw = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
# attempt to parse model_answer
|
||||
correct = eval_answer(str(model_answer_raw), str(answer))
|
||||
@@ -137,7 +139,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -18,6 +18,9 @@ from openhands.core.logger import get_console_handler
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import Action
|
||||
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
|
||||
|
||||
|
||||
class EvalMetadata(BaseModel):
|
||||
@@ -112,7 +115,14 @@ def codeact_user_response(
|
||||
if state.history:
|
||||
# check if the last action has an answer, if so, early exit
|
||||
if try_parse is not None:
|
||||
last_action = state.history.get_last_action()
|
||||
last_action = next(
|
||||
(
|
||||
event
|
||||
for event in reversed(state.history)
|
||||
if isinstance(event, Action)
|
||||
),
|
||||
None,
|
||||
)
|
||||
ans = try_parse(last_action)
|
||||
if ans is not None:
|
||||
return '/exit'
|
||||
@@ -120,7 +130,7 @@ def codeact_user_response(
|
||||
# check if the agent has tried to talk to the user 3 times, if so, let the agent know it can give up
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) >= 2:
|
||||
@@ -336,6 +346,7 @@ def run_evaluation(
|
||||
f'model {metadata.llm_config.model}, max iterations {metadata.max_iterations}.\n'
|
||||
)
|
||||
else:
|
||||
logger.warning('Running evaluation without metadata.')
|
||||
logger.info(f'Evaluation started with {num_workers} workers.')
|
||||
|
||||
total_instances = len(dataset)
|
||||
@@ -428,3 +439,18 @@ def update_llm_config_for_completions_logging(
|
||||
f'{llm_config.log_completions_folder}'
|
||||
)
|
||||
return llm_config
|
||||
|
||||
|
||||
# history is now available as a filtered stream of events, rather than list of pairs of (Action, Observation)
|
||||
# we rebuild the pairs here
|
||||
# for compatibility with the existing output format in evaluations
|
||||
# remove this when it's no longer necessary
|
||||
def compatibility_for_eval_history_pairs(
|
||||
history: list[Event],
|
||||
) -> list[tuple[dict, dict]]:
|
||||
history_pairs = []
|
||||
|
||||
for action, observation in get_pairs_from_events(history):
|
||||
history_pairs.append((event_to_dict(action), event_to_dict(observation)))
|
||||
|
||||
return history_pairs
|
||||
|
||||
@@ -10,6 +10,7 @@ import pandas as pd
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -166,7 +167,7 @@ def process_instance(
|
||||
|
||||
# Instruction is the first message from the USER
|
||||
instruction = ''
|
||||
for event in state.history.get_events():
|
||||
for event in state.history:
|
||||
if isinstance(event, MessageAction):
|
||||
instruction = event.content
|
||||
break
|
||||
@@ -178,7 +179,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -2,3 +2,8 @@
|
||||
public/locales/**/*
|
||||
src/i18n/declaration.ts
|
||||
.env
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { clearSession } from "../src/utils/clear-session";
|
||||
import store from "../src/store";
|
||||
import { initialState as browserInitialState } from "../src/state/browserSlice";
|
||||
|
||||
describe("clearSession", () => {
|
||||
beforeEach(() => {
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
|
||||
// Set initial browser state to non-default values
|
||||
store.dispatch({
|
||||
type: "browser/setUrl",
|
||||
payload: "https://example.com",
|
||||
});
|
||||
store.dispatch({
|
||||
type: "browser/setScreenshotSrc",
|
||||
payload: "base64screenshot",
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear localStorage and reset browser state", () => {
|
||||
clearSession();
|
||||
|
||||
// Verify localStorage items were removed
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith("token");
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith("repo");
|
||||
|
||||
// Verify browser state was reset
|
||||
const state = store.getState();
|
||||
expect(state.browser.url).toBe(browserInitialState.url);
|
||||
expect(state.browser.screenshotSrc).toBe(browserInitialState.screenshotSrc);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, afterEach, vi, it, expect } from "vitest";
|
||||
import { ChatInput } from "#/components/chat-input";
|
||||
|
||||
@@ -158,4 +158,46 @@ describe("ChatInput", () => {
|
||||
await user.tab();
|
||||
expect(onBlurMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should handle text paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Fire paste event with text data
|
||||
fireEvent.paste(input!, {
|
||||
clipboardData: {
|
||||
getData: (type: string) => type === 'text/plain' ? 'test paste' : '',
|
||||
files: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle image paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onImagePaste = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Create a paste event with an image file
|
||||
const file = new File(["dummy content"], "image.png", { type: "image/png" });
|
||||
|
||||
// Fire paste event with image data
|
||||
fireEvent.paste(input!, {
|
||||
clipboardData: {
|
||||
getData: () => '',
|
||||
files: [file]
|
||||
}
|
||||
});
|
||||
|
||||
// Verify image paste was handled
|
||||
expect(onImagePaste).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,156 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ChatInterface } from "#/components/chat-interface";
|
||||
import { SocketProvider } from "#/context/socket";
|
||||
import { addUserMessage } from "#/state/chatSlice";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import * as ChatSlice from "#/state/chatSlice";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
|
||||
render(<ChatInterface />, { wrapper: SocketProvider });
|
||||
renderWithProviders(<ChatInterface />);
|
||||
|
||||
describe("Empty state", () => {
|
||||
const { send: sendMock } = vi.hoisted(() => ({
|
||||
send: vi.fn(),
|
||||
}));
|
||||
|
||||
const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
|
||||
useWsClient: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("#/context/socket", async (importActual) => ({
|
||||
...(await importActual<typeof import("#/context/ws-client-provider")>()),
|
||||
useWsClient: useWsClientMock,
|
||||
}));
|
||||
});
|
||||
|
||||
describe.skip("ChatInterface", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.todo("should render suggestions if empty");
|
||||
it("should render suggestions if empty", () => {
|
||||
const { store } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("suggestions")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
store.dispatch(
|
||||
addUserMessage({
|
||||
content: "Hello",
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the default suggestions", () => {
|
||||
renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const repoSuggestions = Object.keys(SUGGESTIONS.repo);
|
||||
|
||||
// check that there are at most 4 suggestions displayed
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
expect(displayedSuggestions.length).toBeLessThanOrEqual(4);
|
||||
|
||||
// Check that each displayed suggestion is one of the repo suggestions
|
||||
displayedSuggestions.forEach((suggestion) => {
|
||||
expect(repoSuggestions).toContain(suggestion.textContent);
|
||||
});
|
||||
});
|
||||
|
||||
it.fails(
|
||||
"should load the a user message to the input when selecting",
|
||||
async () => {
|
||||
// this is to test that the message is in the UI before the socket is called
|
||||
useWsClientMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
runtimeActive: false, // mock an inactive runtime setup
|
||||
}));
|
||||
const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage");
|
||||
const user = userEvent.setup();
|
||||
const { store } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
const input = screen.getByTestId("chat-input");
|
||||
|
||||
await user.click(displayedSuggestions[0]);
|
||||
|
||||
// user message loaded to input
|
||||
expect(addUserMessageSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("suggestions")).toBeInTheDocument();
|
||||
expect(store.getState().chat.messages).toHaveLength(0);
|
||||
expect(input).toHaveValue(displayedSuggestions[0].textContent);
|
||||
},
|
||||
);
|
||||
|
||||
it.fails(
|
||||
"should send the message to the socket only if the runtime is active",
|
||||
async () => {
|
||||
useWsClientMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
runtimeActive: false, // mock an inactive runtime setup
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
|
||||
await user.click(displayedSuggestions[0]);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
|
||||
useWsClientMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
runtimeActive: true, // mock an active runtime setup
|
||||
}));
|
||||
rerender(<ChatInterface />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendMock).toHaveBeenCalledWith(expect.any(String)),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skip("ChatInterface", () => {
|
||||
beforeAll(() => {
|
||||
// mock useScrollToBottom hook
|
||||
vi.mock("#/hooks/useScrollToBottom", () => ({
|
||||
useScrollToBottom: vi.fn(() => ({
|
||||
scrollDomToBottom: vi.fn(),
|
||||
onChatBodyScroll: vi.fn(),
|
||||
hitBottom: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render messages", () => {
|
||||
const messages: Message[] = [
|
||||
@@ -128,14 +265,14 @@ describe.skip("ChatInterface", () => {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
error: "Woops!",
|
||||
error: true,
|
||||
id: "",
|
||||
message: "Something went wrong",
|
||||
},
|
||||
];
|
||||
renderChatInterface(messages);
|
||||
|
||||
const error = screen.getByTestId("error-message");
|
||||
expect(within(error).getByText("Woops!")).toBeInTheDocument();
|
||||
expect(within(error).getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,21 @@ describe("InteractiveChatBox", () => {
|
||||
within(chatBox).getByTestId("upload-image-input");
|
||||
});
|
||||
|
||||
it.fails("should set custom values", () => {
|
||||
render(
|
||||
<InteractiveChatBox
|
||||
onSubmit={onSubmitMock}
|
||||
onStop={onStopMock}
|
||||
value="Hello, world!"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chatBox = screen.getByTestId("interactive-chat-box");
|
||||
const chatInput = within(chatBox).getByTestId("chat-input");
|
||||
|
||||
expect(chatInput).toHaveValue("Hello, world!");
|
||||
});
|
||||
|
||||
it("should display the image previews when images are uploaded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SuggestionItem } from "#/components/suggestion-item";
|
||||
|
||||
describe("SuggestionItem", () => {
|
||||
const suggestionItem = { label: "suggestion1", value: "a long text value" };
|
||||
const onClick = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render a suggestion", () => {
|
||||
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
|
||||
|
||||
expect(screen.getByTestId("suggestion")).toBeInTheDocument();
|
||||
expect(screen.getByText(/suggestion1/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicking a suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
|
||||
|
||||
const suggestion = screen.getByTestId("suggestion");
|
||||
await user.click(suggestion);
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith("a long text value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Suggestions } from "#/components/suggestions";
|
||||
|
||||
describe("Suggestions", () => {
|
||||
const firstSuggestion = {
|
||||
label: "first-suggestion",
|
||||
value: "value-of-first-suggestion",
|
||||
};
|
||||
const secondSuggestion = {
|
||||
label: "second-suggestion",
|
||||
value: "value-of-second-suggestion",
|
||||
};
|
||||
const suggestions = [firstSuggestion, secondSuggestion];
|
||||
|
||||
const onSuggestionClickMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render suggestions", () => {
|
||||
render(
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSuggestionClick={onSuggestionClickMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("suggestions")).toBeInTheDocument();
|
||||
const suggestionElements = screen.getAllByTestId("suggestion");
|
||||
|
||||
expect(suggestionElements).toHaveLength(2);
|
||||
expect(suggestionElements[0]).toHaveTextContent("first-suggestion");
|
||||
expect(suggestionElements[1]).toHaveTextContent("second-suggestion");
|
||||
});
|
||||
|
||||
it("should call onSuggestionClick when clicking a suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSuggestionClick={onSuggestionClickMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const suggestionElements = screen.getAllByTestId("suggestion");
|
||||
|
||||
await user.click(suggestionElements[0]);
|
||||
expect(onSuggestionClickMock).toHaveBeenCalledWith(
|
||||
"value-of-first-suggestion",
|
||||
);
|
||||
|
||||
await user.click(suggestionElements[1]);
|
||||
expect(onSuggestionClickMock).toHaveBeenCalledWith(
|
||||
"value-of-second-suggestion",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach } from "node:test";
|
||||
import { useTerminal } from "#/hooks/useTerminal";
|
||||
import { SocketProvider } from "#/context/socket";
|
||||
import { Command } from "#/state/commandSlice";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
@@ -18,6 +19,17 @@ function TestTerminalComponent({
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
interface WrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
function Wrapper({children}: WrapperProps) {
|
||||
return (
|
||||
<WsClientProvider enabled={true} token="NO_JWT" ghToken="NO_GITHUB" settings={null}>{children}</WsClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
const mockTerminal = vi.hoisted(() => ({
|
||||
loadAddon: vi.fn(),
|
||||
@@ -50,7 +62,7 @@ describe("useTerminal", () => {
|
||||
|
||||
it("should render", () => {
|
||||
render(<TestTerminalComponent commands={[]} secrets={[]} />, {
|
||||
wrapper: SocketProvider,
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +73,7 @@ describe("useTerminal", () => {
|
||||
];
|
||||
|
||||
render(<TestTerminalComponent commands={commands} secrets={[]} />, {
|
||||
wrapper: SocketProvider,
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
|
||||
@@ -85,7 +97,7 @@ describe("useTerminal", () => {
|
||||
secrets={[secret, anotherSecret]}
|
||||
/>,
|
||||
{
|
||||
wrapper: SocketProvider,
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import store from "../src/store";
|
||||
import { setInitialQuery, clearInitialQuery } from "../src/state/initial-query-slice";
|
||||
|
||||
describe("Initial Query Behavior", () => {
|
||||
it("should clear initial query when clearInitialQuery is dispatched", () => {
|
||||
// Set up initial query in the store
|
||||
store.dispatch(setInitialQuery("test query"));
|
||||
expect(store.getState().initalQuery.initialQuery).toBe("test query");
|
||||
|
||||
// Clear the initial query
|
||||
store.dispatch(clearInitialQuery());
|
||||
|
||||
// Verify initial query is cleared
|
||||
expect(store.getState().initalQuery.initialQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("App", () => {
|
||||
it.todo("should render");
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach } from "node:test";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cache } from "#/utils/cache";
|
||||
|
||||
describe("Cache", () => {
|
||||
const testKey = "key";
|
||||
const testData = { message: "Hello, world!" };
|
||||
const testTTL = 1000; // 1 second
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("gets data from memory if not expired", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
|
||||
expect(cache.get(testKey)).toEqual(testData);
|
||||
});
|
||||
|
||||
it("should expire after 5 minutes by default", () => {
|
||||
cache.set(testKey, testData);
|
||||
expect(cache.get(testKey)).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if cached data is expired", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
|
||||
vi.advanceTimersByTime(testTTL + 1);
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes data from memory", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.delete(testKey);
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears all data with the app prefix from memory", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.set("anotherKey", { data: "More data" }, testTTL);
|
||||
cache.clearAll();
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
expect(cache.get("anotherKey")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -59,9 +59,9 @@ describe("extractModelAndProvider", () => {
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("claude-3-5-sonnet-20241022")).toEqual({
|
||||
expect(extractModelAndProvider("claude-3-5-sonnet-20240620")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
model: "claude-3-5-sonnet-20240620",
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ test("organizeModelsAndProviders", () => {
|
||||
"gpt-4o",
|
||||
"together-ai-21.1b-41b",
|
||||
"gpt-4o-mini",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-2",
|
||||
"claude-2.1",
|
||||
|
||||
Generated
+85
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
@@ -26,7 +26,7 @@
|
||||
"isbot": "^5.1.17",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"posthog-js": "^1.176.0",
|
||||
"posthog-js": "^1.184.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -46,6 +46,7 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.2",
|
||||
"@remix-run/dev": "^2.11.2",
|
||||
"@remix-run/testing": "^2.11.2",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
@@ -62,6 +63,7 @@
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
@@ -3379,6 +3381,21 @@
|
||||
"url": "https://opencollective.com/unts"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.48.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.2.tgz",
|
||||
"integrity": "sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.48.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.28",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
|
||||
@@ -7907,6 +7924,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "src/bin/cross-env.js",
|
||||
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
@@ -19422,6 +19457,50 @@
|
||||
"pathe": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.48.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.2.tgz",
|
||||
"integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.48.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.48.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz",
|
||||
"integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||
@@ -19670,9 +19749,9 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.176.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.176.0.tgz",
|
||||
"integrity": "sha512-T5XKNtRzp7q6CGb7Vc7wAI76rWap9fiuDUPxPsyPBPDkreKya91x9RIsSapAVFafwD1AEin1QMczCmt9Le9BWw==",
|
||||
"version": "1.184.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.184.1.tgz",
|
||||
"integrity": "sha512-q/1Kdard5SZnL2smrzeKcD+RuUi2PnbidiN4D3ThK20bNrhy5Z2heIy9SnRMvEiARY5lcQ7zxmDCAKPBKGSOtQ==",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"isbot": "^5.1.17",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"posthog-js": "^1.176.0",
|
||||
"posthog-js": "^1.184.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -45,11 +45,12 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run make-i18n && VITE_MOCK_API=false remix vite:dev",
|
||||
"dev:mock": "npm run make-i18n && VITE_MOCK_API=true remix vite:dev",
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false remix vite:dev",
|
||||
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true remix vite:dev",
|
||||
"build": "npm run make-i18n && tsc && remix vite:build",
|
||||
"start": "npx sirv-cli build/ --single",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:coverage": "npm run make-i18n && vitest run --coverage",
|
||||
"dev_wsl": "VITE_WATCH_USE_POLLING=true vite",
|
||||
"preview": "vite preview",
|
||||
@@ -71,6 +72,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.2",
|
||||
"@remix-run/dev": "^2.11.2",
|
||||
"@remix-run/testing": "^2.11.2",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
@@ -87,6 +89,7 @@
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
@@ -117,4 +120,4 @@
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1:3000",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "npm run dev:mock -- --port 3000",
|
||||
url: "http://127.0.0.1:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"APP_MODE": "oss",
|
||||
"GITHUB_CLIENT_ID": ""
|
||||
}
|
||||
"GITHUB_CLIENT_ID": "",
|
||||
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
|
||||
}
|
||||
@@ -122,6 +122,9 @@ export const retrieveGitHubUser = async (
|
||||
id: data.id,
|
||||
login: data.login,
|
||||
avatar_url: data.avatar_url,
|
||||
company: data.company,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
return user;
|
||||
@@ -136,33 +139,6 @@ export const retrieveGitHubUser = async (
|
||||
return error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a GitHub token and a repository name, creates a repository for the authenticated user
|
||||
* @param token The GitHub token
|
||||
* @param repositoryName Name of the repository to create
|
||||
* @param description Description of the repository
|
||||
* @param isPrivate Boolean indicating if the repository should be private
|
||||
* @returns The created repository or an error response
|
||||
*/
|
||||
export const createGitHubRepository = async (
|
||||
token: string,
|
||||
repositoryName: string,
|
||||
description?: string,
|
||||
isPrivate = true,
|
||||
): Promise<GitHubRepository | GitHubErrorReponse> => {
|
||||
const response = await fetch("https://api.github.com/user/repos", {
|
||||
method: "POST",
|
||||
headers: generateGitHubAPIHeaders(token),
|
||||
body: JSON.stringify({
|
||||
name: repositoryName,
|
||||
description,
|
||||
private: isPrivate,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const retrieveLatestGitHubCommit = async (
|
||||
token: string,
|
||||
repository: string,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { request } from "#/services/api";
|
||||
import { cache } from "#/utils/cache";
|
||||
import {
|
||||
SaveFileSuccessResponse,
|
||||
FileUploadSuccessResponse,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
GitHubAccessTokenResponse,
|
||||
ErrorResponse,
|
||||
GetConfigResponse,
|
||||
GetVSCodeUrlResponse,
|
||||
} from "./open-hands.types";
|
||||
|
||||
class OpenHands {
|
||||
@@ -15,7 +17,13 @@ class OpenHands {
|
||||
* @returns List of models available
|
||||
*/
|
||||
static async getModels(): Promise<string[]> {
|
||||
return request("/api/options/models");
|
||||
const cachedData = cache.get<string[]>("models");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request("/api/options/models");
|
||||
cache.set("models", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,7 +31,13 @@ class OpenHands {
|
||||
* @returns List of agents available
|
||||
*/
|
||||
static async getAgents(): Promise<string[]> {
|
||||
return request(`/api/options/agents`);
|
||||
const cachedData = cache.get<string[]>("agents");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request(`/api/options/agents`);
|
||||
cache.set("agents", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,11 +45,23 @@ class OpenHands {
|
||||
* @returns List of security analyzers available
|
||||
*/
|
||||
static async getSecurityAnalyzers(): Promise<string[]> {
|
||||
return request(`/api/options/security-analyzers`);
|
||||
const cachedData = cache.get<string[]>("agents");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request(`/api/options/security-analyzers`);
|
||||
cache.set("security-analyzers", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getConfig(): Promise<GetConfigResponse> {
|
||||
return request("/config.json");
|
||||
const cachedData = cache.get<GetConfigResponse>("config");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request("/config.json");
|
||||
cache.set("config", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +175,14 @@ class OpenHands {
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VSCode URL
|
||||
* @returns VSCode URL
|
||||
*/
|
||||
static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
|
||||
return request(`/api/vscode-url`, {}, false, false, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -43,5 +43,11 @@ export interface Feedback {
|
||||
|
||||
export interface GetConfigResponse {
|
||||
APP_MODE: "saas" | "oss";
|
||||
GITHUB_CLIENT_ID: string | null;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
}
|
||||
|
||||
export interface GetVSCodeUrlResponse {
|
||||
vscode_url: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<g filter="url(#filter0_d)">
|
||||
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.9119 99.5723C72.4869 100.189 74.2828 100.15 75.8725 99.3807L96.4604 89.4231C98.624 88.3771 100 86.1762 100 83.7616V16.2392C100 13.8247 98.624 11.6238 96.4604 10.5774L75.8725 0.619067C73.7862 -0.389991 71.3446 -0.142885 69.5135 1.19527C69.252 1.38636 69.0028 1.59985 68.769 1.83502L29.3551 37.9795L12.1872 24.88C10.5891 23.6607 8.35365 23.7606 6.86938 25.1178L1.36302 30.1525C-0.452603 31.8127 -0.454583 34.6837 1.35854 36.3466L16.2471 50.0001L1.35854 63.6536C-0.454583 65.3164 -0.452603 68.1876 1.36302 69.8477L6.86938 74.8824C8.35365 76.2395 10.5891 76.34 12.1872 75.1201L29.3551 62.0207L68.769 98.1651C69.3925 98.7923 70.1246 99.2645 70.9119 99.5723ZM75.0152 27.1813L45.1092 50.0001L75.0152 72.8189V27.1813Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M96.4614 10.593L75.8567 0.62085C73.4717 -0.533437 70.6215 -0.0465506 68.7498 1.83492L1.29834 63.6535C-0.515935 65.3164 -0.513852 68.1875 1.30281 69.8476L6.8125 74.8823C8.29771 76.2395 10.5345 76.339 12.1335 75.1201L93.3604 13.18C96.0854 11.102 100 13.0557 100 16.4939V16.2535C100 13.84 98.6239 11.64 96.4614 10.593Z" fill="#D9D9D9"/>
|
||||
<g filter="url(#filter1_d)">
|
||||
<path d="M96.4614 89.4074L75.8567 99.3797C73.4717 100.534 70.6215 100.047 68.7498 98.1651L1.29834 36.3464C-0.515935 34.6837 -0.513852 31.8125 1.30281 30.1524L6.8125 25.1177C8.29771 23.7605 10.5345 23.6606 12.1335 24.88L93.3604 86.8201C96.0854 88.8985 100 86.9447 100 83.5061V83.747C100 86.1604 98.6239 88.3603 96.4614 89.4074Z" fill="#E6E6E6"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d)">
|
||||
<path d="M75.8578 99.3807C73.4721 100.535 70.6219 100.047 68.75 98.1651C71.0564 100.483 75 98.8415 75 95.5631V4.43709C75 1.15852 71.0565 -0.483493 68.75 1.83492C70.6219 -0.0467614 73.4721 -0.534276 75.8578 0.618963L96.4583 10.5773C98.6229 11.6237 100 13.8246 100 16.2391V83.7616C100 86.1762 98.6229 88.3761 96.4583 89.4231L75.8578 99.3807Z" fill="white"/>
|
||||
</g>
|
||||
<g style="mix-blend-mode:overlay" opacity="0.25">
|
||||
<path style="mix-blend-mode:overlay" opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M70.8508 99.5723C72.4258 100.189 74.2218 100.15 75.8115 99.3807L96.4 89.4231C98.5635 88.3771 99.9386 86.1762 99.9386 83.7616V16.2391C99.9386 13.8247 98.5635 11.6239 96.4 10.5774L75.8115 0.618974C73.7252 -0.390085 71.2835 -0.142871 69.4525 1.19518C69.1909 1.38637 68.9418 1.59976 68.7079 1.83493L29.2941 37.9795L12.1261 24.88C10.528 23.6606 8.2926 23.7605 6.80833 25.1177L1.30198 30.1524C-0.51354 31.8126 -0.515625 34.6837 1.2975 36.3465L16.186 50L1.2975 63.6536C-0.515625 65.3164 -0.51354 68.1875 1.30198 69.8476L6.80833 74.8824C8.2926 76.2395 10.528 76.339 12.1261 75.1201L29.2941 62.0207L68.7079 98.1651C69.3315 98.7923 70.0635 99.2645 70.8508 99.5723ZM74.9542 27.1812L45.0481 50L74.9542 72.8188V27.1812Z" fill="url(#paint0_linear)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d" x="-6.25" y="-4.16667" width="112.5" height="112.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset dy="2.08333"/>
|
||||
<feGaussianBlur stdDeviation="3.125"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d" x="-8.39436" y="15.6951" width="116.728" height="92.6376" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="4.16667"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d" x="60.4167" y="-8.33346" width="47.9167" height="116.667" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="4.16667"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear" x1="49.939" y1="-5.19792e-05" x2="49.939" y2="100.001" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0">
|
||||
<rect width="100" height="100" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -6,7 +6,7 @@ import PlayIcon from "#/assets/play";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
|
||||
import { RootState } from "#/store";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
const IgnoreTaskStateMap: Record<string, AgentState[]> = {
|
||||
[AgentState.PAUSED]: [
|
||||
@@ -72,7 +72,7 @@ function ActionButton({
|
||||
}
|
||||
|
||||
function AgentControlBar() {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const handleAction = (action: AgentState) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import AgentState from "#/types/AgentState";
|
||||
@@ -16,7 +17,7 @@ enum IndicatorColor {
|
||||
}
|
||||
|
||||
function AgentStatusBar() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const { curStatusMessage } = useSelector((state: RootState) => state.status);
|
||||
|
||||
@@ -94,15 +95,27 @@ function AgentStatusBar() {
|
||||
const [statusMessage, setStatusMessage] = React.useState<string>("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (curAgentState === AgentState.LOADING) {
|
||||
const trimmedCustomMessage = curStatusMessage.status.trim();
|
||||
if (trimmedCustomMessage) {
|
||||
setStatusMessage(t(trimmedCustomMessage));
|
||||
return;
|
||||
let message = curStatusMessage.message || "";
|
||||
if (curStatusMessage?.id) {
|
||||
const id = curStatusMessage.id.trim();
|
||||
if (i18n.exists(id)) {
|
||||
message = t(curStatusMessage.id.trim()) || message;
|
||||
}
|
||||
}
|
||||
if (curStatusMessage?.type === "error") {
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (curAgentState === AgentState.LOADING && message.trim()) {
|
||||
setStatusMessage(message);
|
||||
} else {
|
||||
setStatusMessage(AgentStatusMap[curAgentState].message);
|
||||
}
|
||||
}, [curStatusMessage.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setStatusMessage(AgentStatusMap[curAgentState].message);
|
||||
}, [curAgentState, curStatusMessage.status]);
|
||||
}, [curAgentState]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
|
||||
@@ -25,7 +25,11 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
<SyntaxHighlighter language="python" style={atomOneDark}>
|
||||
<SyntaxHighlighter
|
||||
language="python"
|
||||
style={atomOneDark}
|
||||
wrapLongLines
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</pre>
|
||||
@@ -78,7 +82,11 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function JupyterEditor(): JSX.Element {
|
||||
interface JupyterEditorProps {
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { cells } = useSelector((state: RootState) => state.jupyter);
|
||||
@@ -88,7 +96,7 @@ function JupyterEditor(): JSX.Element {
|
||||
useScrollToBottom(jupyterRef);
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="flex-1" style={{ maxWidth }}>
|
||||
<div
|
||||
className="overflow-y-auto h-full"
|
||||
ref={jupyterRef}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Clip from "#/assets/clip.svg?react";
|
||||
import Clip from "#/icons/clip.svg?react";
|
||||
|
||||
export function AttachImageLabel() {
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
interface ModalButtonProps {
|
||||
testId?: string;
|
||||
variant?: "default" | "text-like";
|
||||
onClick?: () => void;
|
||||
text: string;
|
||||
@@ -13,6 +14,7 @@ interface ModalButtonProps {
|
||||
}
|
||||
|
||||
function ModalButton({
|
||||
testId,
|
||||
variant = "default",
|
||||
onClick,
|
||||
text,
|
||||
@@ -24,6 +26,7 @@ function ModalButton({
|
||||
}: ModalButtonProps) {
|
||||
return (
|
||||
<button
|
||||
data-testid={testId}
|
||||
type={type === "submit" ? "submit" : "button"}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import ArrowSendIcon from "#/assets/arrow-send.svg?react";
|
||||
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ChatInputProps {
|
||||
@@ -16,6 +16,7 @@ interface ChatInputProps {
|
||||
onChange?: (message: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onImagePaste?: (files: File[]) => void;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
}
|
||||
|
||||
@@ -32,9 +33,51 @@ export function ChatInput({
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onImagePaste,
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// Only handle paste if we have an image paste handler and there are files
|
||||
if (onImagePaste && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
// Only prevent default if we found image files to handle
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onImagePaste(files);
|
||||
}
|
||||
}
|
||||
// For text paste, let the default behavior handle it
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.types.includes("Files")) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
if (onImagePaste && event.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
if (files.length > 0) {
|
||||
onImagePaste(files);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitMessage = () => {
|
||||
if (textareaRef.current?.value) {
|
||||
@@ -67,12 +110,20 @@ export function ChatInput({
|
||||
onChange={handleChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPaste={handlePaste}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
value={value}
|
||||
minRows={1}
|
||||
maxRows={maxRows}
|
||||
data-dragging-over={isDraggingOver}
|
||||
className={cn(
|
||||
"grow text-sm self-center placeholder:text-neutral-400 text-white resize-none bg-transparent outline-none ring-0",
|
||||
"transition-[height] duration-200 ease-in-out",
|
||||
"grow text-sm self-center placeholder:text-neutral-400 text-white resize-none outline-none ring-0",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
isDraggingOver
|
||||
? "bg-neutral-600/50 rounded-lg px-2"
|
||||
: "bg-transparent",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React from "react";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import posthog from "posthog-js";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { FeedbackActions } from "./feedback-actions";
|
||||
@@ -18,13 +18,17 @@ import ConfirmationButtons from "./chat/ConfirmationButtons";
|
||||
import { ErrorMessage } from "./error-message";
|
||||
import { ContinueButton } from "./continue-button";
|
||||
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
|
||||
import { Suggestions } from "./suggestions";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import BuildIt from "#/icons/build-it.svg?react";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
const isErrorMessage = (
|
||||
message: Message | ErrorMessage,
|
||||
): message is ErrorMessage => "error" in message;
|
||||
|
||||
export function ChatInterface() {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const dispatch = useDispatch();
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
|
||||
@@ -37,17 +41,23 @@ export function ChatInterface() {
|
||||
"positive" | "negative"
|
||||
>("positive");
|
||||
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
|
||||
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
|
||||
|
||||
const handleSendMessage = async (content: string, files: File[]) => {
|
||||
posthog.capture("user_message_sent", {
|
||||
current_message_count: messages.length,
|
||||
});
|
||||
const promises = files.map((file) => convertImageToBase64(file));
|
||||
const imageUrls = await Promise.all(promises);
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
dispatch(addUserMessage({ content, imageUrls, timestamp }));
|
||||
send(createChatMessage(content, imageUrls, timestamp));
|
||||
setMessageToSend(null);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
posthog.capture("stop_button_clicked");
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
@@ -64,6 +74,28 @@ export function ChatInterface() {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
|
||||
<div className="flex flex-col items-center p-4 bg-neutral-700 rounded-xl w-full">
|
||||
<BuildIt width={45} height={54} />
|
||||
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
|
||||
Let's start building!
|
||||
</span>
|
||||
</div>
|
||||
<Suggestions
|
||||
suggestions={Object.entries(SUGGESTIONS.repo)
|
||||
.slice(0, 4)
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
onSuggestionClick={(value) => {
|
||||
setMessageToSend(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
@@ -73,7 +105,7 @@ export function ChatInterface() {
|
||||
isErrorMessage(message) ? (
|
||||
<ErrorMessage
|
||||
key={index}
|
||||
error={message.error}
|
||||
id={message.id}
|
||||
message={message.message}
|
||||
/>
|
||||
) : (
|
||||
@@ -123,6 +155,8 @@ export function ChatInterface() {
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
|
||||
value={messageToSend ?? undefined}
|
||||
onChange={setMessageToSend}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import RejectIcon from "#/assets/reject";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
interface ActionTooltipProps {
|
||||
type: "confirm" | "reject";
|
||||
@@ -37,7 +37,7 @@ function ActionTooltip({ type, onClick }: ActionTooltipProps) {
|
||||
|
||||
function ConfirmationButtons() {
|
||||
const { t } = useTranslation();
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
|
||||
const handleStateChange = (state: AgentState) => {
|
||||
const event = generateAgentStateChangeEvent(state);
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ type Message = {
|
||||
};
|
||||
|
||||
type ErrorMessage = {
|
||||
error: string;
|
||||
error: boolean;
|
||||
id?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ErrorMessageProps {
|
||||
error: string;
|
||||
id?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function ErrorMessage({ error, message }: ErrorMessageProps) {
|
||||
export function ErrorMessage({ id, message }: ErrorMessageProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showDetails, setShowDetails] = useState(true);
|
||||
const [headline, setHeadline] = useState("");
|
||||
const [details, setDetails] = useState(message);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && i18n.exists(id)) {
|
||||
setHeadline(t(id));
|
||||
setDetails(message);
|
||||
setShowDetails(false);
|
||||
}
|
||||
}, [id, message, i18n.language]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center justify-start border-l-2 border-danger pl-2 my-2 py-2">
|
||||
<div className="text-sm leading-4 flex flex-col gap-2">
|
||||
<p className="text-danger font-bold">{error}</p>
|
||||
<p className="text-neutral-300">{message}</p>
|
||||
{headline && <p className="text-danger font-bold">{headline}</p>}
|
||||
{headline && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="cursor-pointer text-left"
|
||||
>
|
||||
{showDetails
|
||||
? t("ERROR_MESSAGE$HIDE_DETAILS")
|
||||
: t("ERROR_MESSAGE$SHOW_DETAILS")}
|
||||
</button>
|
||||
)}
|
||||
{showDetails && <p className="text-neutral-300">{details}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useRouteLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { addErrorMessage, addUserMessage } from "#/state/chatSlice";
|
||||
import {
|
||||
getCloneRepoCommand,
|
||||
getGitHubTokenCommand,
|
||||
} from "#/services/terminalService";
|
||||
import {
|
||||
clearFiles,
|
||||
clearSelectedRepository,
|
||||
setImportedProjectZip,
|
||||
} from "#/state/initial-query-slice";
|
||||
import { clientLoader as appClientLoader } from "#/routes/_oh.app";
|
||||
import store, { RootState } from "#/store";
|
||||
import { createChatMessage } from "#/services/chatService";
|
||||
import { clientLoader as rootClientLoader } from "#/routes/_oh";
|
||||
import { isGitHubErrorReponse } from "#/api/github";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { base64ToBlob } from "#/utils/base64-to-blob";
|
||||
import { setCurrentAgentState } from "#/state/agentSlice";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { getSettings } from "#/services/settings";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const isServerError = (data: object): data is ServerError => "error" in data;
|
||||
|
||||
const isErrorObservation = (data: object): data is ErrorObservation =>
|
||||
"observation" in data && data.observation === "error";
|
||||
|
||||
export function EventHandler({ children }: React.PropsWithChildren) {
|
||||
const { events, status, send } = useWsClient();
|
||||
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
|
||||
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
|
||||
const fetcher = useFetcher();
|
||||
const dispatch = useDispatch();
|
||||
const { files, importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initalQuery,
|
||||
);
|
||||
const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
|
||||
const initialQueryRef = React.useRef<string | null>(
|
||||
store.getState().initalQuery.initialQuery,
|
||||
);
|
||||
|
||||
const sendInitialQuery = (query: string, base64Files: string[]) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
send(createChatMessage(query, base64Files, timestamp));
|
||||
};
|
||||
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
|
||||
const userId = React.useMemo(() => {
|
||||
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
|
||||
return null;
|
||||
}, [data?.user]);
|
||||
const userSettings = getSettings();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
const event = events[events.length - 1];
|
||||
if (event.token) {
|
||||
fetcher.submit({ token: event.token as string }, { method: "post" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isServerError(event)) {
|
||||
if (event.error_code === 401) {
|
||||
toast.error("Session expired.");
|
||||
fetcher.submit({}, { method: "POST", action: "/end-session" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.error === "string") {
|
||||
toast.error(event.error);
|
||||
} else {
|
||||
toast.error(event.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
const message: string = `${event.message}`;
|
||||
if (message.startsWith("Agent reached maximum")) {
|
||||
// We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations
|
||||
send(generateAgentStateChangeEvent(AgentState.PAUSED));
|
||||
}
|
||||
}
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
dispatch(
|
||||
addErrorMessage({
|
||||
id: event.extras?.error_id,
|
||||
message: event.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [events.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (statusRef.current === status) {
|
||||
return; // This is a check because of strict mode - if the status did not change, don't do anything
|
||||
}
|
||||
statusRef.current = status;
|
||||
const initialQuery = initialQueryRef.current;
|
||||
|
||||
if (status === WsClientProviderStatus.ACTIVE) {
|
||||
let additionalInfo = "";
|
||||
if (ghToken && repo) {
|
||||
send(getCloneRepoCommand(ghToken, repo));
|
||||
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
|
||||
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
|
||||
}
|
||||
// if there's an uploaded project zip, add it to the chat
|
||||
else if (importedProjectZip) {
|
||||
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
|
||||
}
|
||||
|
||||
if (initialQuery) {
|
||||
if (additionalInfo) {
|
||||
sendInitialQuery(`${initialQuery}\n\n[${additionalInfo}]`, files);
|
||||
} else {
|
||||
sendInitialQuery(initialQuery, files);
|
||||
}
|
||||
dispatch(clearFiles()); // reset selected files
|
||||
initialQueryRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === WsClientProviderStatus.OPENING && initialQuery) {
|
||||
dispatch(
|
||||
addUserMessage({
|
||||
content: initialQuery,
|
||||
imageUrls: files,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (status === WsClientProviderStatus.STOPPED) {
|
||||
store.dispatch(setCurrentAgentState(AgentState.STOPPED));
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (runtimeActive && userId && ghToken) {
|
||||
// Export if the user valid, this could happen mid-session so it is handled here
|
||||
send(getGitHubTokenCommand(ghToken));
|
||||
}
|
||||
}, [userId, ghToken, runtimeActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (runtimeActive && importedProjectZip) {
|
||||
// upload files action
|
||||
try {
|
||||
const blob = base64ToBlob(importedProjectZip);
|
||||
const file = new File([blob], "imported-project.zip", {
|
||||
type: blob.type,
|
||||
});
|
||||
await OpenHands.uploadFiles([file]);
|
||||
dispatch(setImportedProjectZip(null));
|
||||
} catch (error) {
|
||||
toast.error("Failed to upload project files.");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [runtimeActive, importedProjectZip]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (userSettings.LLM_API_KEY) {
|
||||
posthog.capture("user_activated");
|
||||
}
|
||||
}, [userSettings.LLM_API_KEY]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { setRefreshID } from "#/state/codeSlice";
|
||||
import { addAssistantMessage } from "#/state/chatSlice";
|
||||
import IconButton from "../IconButton";
|
||||
import ExplorerTree from "./ExplorerTree";
|
||||
import toast from "#/utils/toast";
|
||||
@@ -20,6 +21,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useFiles } from "#/context/files";
|
||||
import { isOpenHandsErrorResponse } from "#/api/open-hands.utils";
|
||||
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
|
||||
|
||||
interface ExplorerActionsProps {
|
||||
onRefresh: () => void;
|
||||
@@ -168,6 +170,35 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVSCodeClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await OpenHands.getVSCodeUrl();
|
||||
if (response.vscode_url) {
|
||||
dispatch(
|
||||
addAssistantMessage(
|
||||
"You opened VS Code. Please inform the agent of any changes you made to the workspace or environment. To avoid conflicts, it's best to pause the agent before making any changes.",
|
||||
),
|
||||
);
|
||||
window.open(response.vscode_url, "_blank");
|
||||
} else {
|
||||
toast.error(
|
||||
`open-vscode-error-${new Date().getTime()}`,
|
||||
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
|
||||
error: response.error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (exp_error) {
|
||||
toast.error(
|
||||
`open-vscode-error-${new Date().getTime()}`,
|
||||
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
|
||||
error: String(exp_error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
refreshWorkspace();
|
||||
}, [curAgentState]);
|
||||
@@ -210,7 +241,7 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
|
||||
!isOpen ? "w-12" : "w-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col relative h-full px-3 py-2">
|
||||
<div className="flex flex-col relative h-full px-3 py-2 overflow-hidden">
|
||||
<div className="sticky top-0 bg-neutral-800">
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -232,7 +263,7 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
|
||||
</div>
|
||||
</div>
|
||||
{!error && (
|
||||
<div className="overflow-auto flex-grow">
|
||||
<div className="overflow-auto flex-grow min-h-0">
|
||||
<div style={{ display: !isOpen ? "none" : "block" }}>
|
||||
<ExplorerTree files={paths} />
|
||||
</div>
|
||||
@@ -243,6 +274,27 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
|
||||
<p className="text-neutral-300 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVSCodeClick}
|
||||
disabled={
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING
|
||||
}
|
||||
className={twMerge(
|
||||
"mt-auto mb-2 w-full h-10 text-white rounded flex items-center justify-center gap-2 transition-colors",
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING
|
||||
? "bg-neutral-600 cursor-not-allowed"
|
||||
: "bg-[#4465DB] hover:bg-[#3451C7]",
|
||||
)}
|
||||
aria-label="Open in VS Code"
|
||||
>
|
||||
<VSCodeIcon width={20} height={20} />
|
||||
Open in VS Code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
data-testid="file-input"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface CustomInputProps {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -13,12 +16,19 @@ export function CustomInput({
|
||||
defaultValue,
|
||||
type = "text",
|
||||
}: CustomInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label htmlFor={name} className="flex flex-col gap-2">
|
||||
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3]">
|
||||
{label}
|
||||
{required && <span className="text-[#FF4D4F]">*</span>}
|
||||
{!required && <span className="text-[#A3A3A3]"> (optional)</span>}
|
||||
{!required && (
|
||||
<span className="text-[#A3A3A3]">
|
||||
{" "}
|
||||
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
id={name}
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
Switch,
|
||||
} from "@nextui-org/react";
|
||||
import { useFetcher, useLocation, useNavigate } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
|
||||
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
|
||||
import { clientAction } from "#/routes/settings";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
|
||||
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
|
||||
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
|
||||
import { clientAction } from "#/routes/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
import { DangerModal } from "../modals/confirmation-modals/danger-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@@ -35,6 +37,7 @@ export function SettingsForm({
|
||||
}: SettingsFormProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fetcher = useFetcher<typeof clientAction>();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
@@ -161,7 +164,7 @@ export function SettingsForm({
|
||||
label: "text-[#A3A3A3] text-xs",
|
||||
}}
|
||||
>
|
||||
Advanced Options
|
||||
{t(I18nKey.SETTINGS_FORM$ADVANCED_OPTIONS_LABEL)}
|
||||
</Switch>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
@@ -171,7 +174,7 @@ export function SettingsForm({
|
||||
htmlFor="custom-model"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Custom Model
|
||||
{t(I18nKey.SETTINGS_FORM$CUSTOM_MODEL_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={disabled}
|
||||
@@ -190,7 +193,7 @@ export function SettingsForm({
|
||||
htmlFor="base-url"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Base URL
|
||||
{t(I18nKey.SETTINGS_FORM$BASE_URL_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={disabled}
|
||||
@@ -220,7 +223,7 @@ export function SettingsForm({
|
||||
htmlFor="api-key"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
API Key
|
||||
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={disabled}
|
||||
@@ -234,14 +237,14 @@ export function SettingsForm({
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-[#A3A3A3]">
|
||||
Don't know your API key?{" "}
|
||||
{t(I18nKey.SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL)}{" "}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Click here for instructions
|
||||
{t(I18nKey.SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL)}
|
||||
</a>
|
||||
</p>
|
||||
</fieldset>
|
||||
@@ -255,7 +258,7 @@ export function SettingsForm({
|
||||
htmlFor="agent"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Agent
|
||||
{t(I18nKey.SETTINGS_FORM$AGENT_LABEL)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
isDisabled={disabled}
|
||||
@@ -291,7 +294,7 @@ export function SettingsForm({
|
||||
htmlFor="security-analyzer"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Security Analyzer (Optional)
|
||||
{t(I18nKey.SETTINGS_FORM$SECURITY_ANALYZER_LABEL)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
isDisabled={disabled}
|
||||
@@ -334,7 +337,7 @@ export function SettingsForm({
|
||||
label: "text-[#A3A3A3] text-xs",
|
||||
}}
|
||||
>
|
||||
Enable Confirmation Mode
|
||||
{t(I18nKey.SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL)}
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
@@ -345,18 +348,18 @@ export function SettingsForm({
|
||||
<ModalButton
|
||||
disabled={disabled || fetcher.state === "submitting"}
|
||||
type="submit"
|
||||
text="Save"
|
||||
text={t(I18nKey.SETTINGS_FORM$SAVE_LABEL)}
|
||||
className="bg-[#4465DB] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
text="Close"
|
||||
text={t(I18nKey.SETTINGS_FORM$CLOSE_LABEL)}
|
||||
className="bg-[#737373] w-full"
|
||||
onClick={handleCloseClick}
|
||||
/>
|
||||
</div>
|
||||
<ModalButton
|
||||
disabled={disabled}
|
||||
text="Reset to defaults"
|
||||
text={t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL)}
|
||||
variant="text-like"
|
||||
className="text-danger self-start"
|
||||
onClick={() => {
|
||||
@@ -369,15 +372,17 @@ export function SettingsForm({
|
||||
{confirmResetDefaultsModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
title="Are you sure?"
|
||||
description="All saved information in your AI settings will be deleted including any API keys."
|
||||
title={t(I18nKey.SETTINGS_FORM$ARE_YOU_SURE_LABEL)}
|
||||
description={t(
|
||||
I18nKey.SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE,
|
||||
)}
|
||||
buttons={{
|
||||
danger: {
|
||||
text: "Reset Defaults",
|
||||
text: t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL),
|
||||
onClick: handleConfirmResetSettings,
|
||||
},
|
||||
cancel: {
|
||||
text: "Cancel",
|
||||
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
|
||||
onClick: () => setConfirmResetDefaultsModalOpen(false),
|
||||
},
|
||||
}}
|
||||
@@ -387,12 +392,17 @@ export function SettingsForm({
|
||||
{confirmEndSessionModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
title="End Session"
|
||||
description="Changing your settings will clear your workspace and start a new session. Are you sure you want to continue?"
|
||||
title={t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL)}
|
||||
description={t(
|
||||
I18nKey.SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE,
|
||||
)}
|
||||
buttons={{
|
||||
danger: { text: "End Session", onClick: handleConfirmEndSession },
|
||||
danger: {
|
||||
text: t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL),
|
||||
onClick: handleConfirmEndSession,
|
||||
},
|
||||
cancel: {
|
||||
text: "Cancel",
|
||||
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
|
||||
onClick: () => setConfirmEndSessionModalOpen(false),
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CloseIcon from "#/assets/close.svg?react";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ImagePreviewProps {
|
||||
|
||||
@@ -9,6 +9,8 @@ interface InteractiveChatBoxProps {
|
||||
mode?: "stop" | "submit";
|
||||
onSubmit: (message: string, images: File[]) => void;
|
||||
onStop: () => void;
|
||||
value?: string;
|
||||
onChange?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function InteractiveChatBox({
|
||||
@@ -16,6 +18,8 @@ export function InteractiveChatBox({
|
||||
mode = "submit",
|
||||
onSubmit,
|
||||
onStop,
|
||||
value,
|
||||
onChange,
|
||||
}: InteractiveChatBoxProps) {
|
||||
const [images, setImages] = React.useState<File[]>([]);
|
||||
|
||||
@@ -53,6 +57,8 @@ export function InteractiveChatBox({
|
||||
className={cn(
|
||||
"flex items-end gap-1",
|
||||
"bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
|
||||
"transition-colors duration-200",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
)}
|
||||
>
|
||||
<UploadImageInput onUpload={handleUpload} />
|
||||
@@ -60,8 +66,11 @@ export function InteractiveChatBox({
|
||||
disabled={isDisabled}
|
||||
button={mode}
|
||||
placeholder="What do you want to build?"
|
||||
onChange={onChange}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
value={value}
|
||||
onImagePaste={handleUpload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseModalTitle } from "./confirmation-modals/BaseModal";
|
||||
import ModalBody from "./ModalBody";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
@@ -9,6 +10,7 @@ import { clientLoader } from "#/routes/_oh";
|
||||
import { clientAction as settingsClientAction } from "#/routes/settings";
|
||||
import { clientAction as loginClientAction } from "#/routes/login";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface AccountSettingsModalProps {
|
||||
onClose: () => void;
|
||||
@@ -23,6 +25,7 @@ function AccountSettingsModal({
|
||||
gitHubError,
|
||||
analyticsConsent,
|
||||
}: AccountSettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
|
||||
const settingsFetcher = useFetcher<typeof settingsClientAction>({
|
||||
key: "settings",
|
||||
@@ -86,13 +89,13 @@ function AccountSettingsModal({
|
||||
/>
|
||||
{gitHubError && (
|
||||
<p className="text-danger text-xs">
|
||||
GitHub token is invalid. Please try again.
|
||||
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{data?.ghToken && !gitHubError && (
|
||||
<ModalButton
|
||||
variant="text-like"
|
||||
text="Disconnect"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
|
||||
onClick={() => {
|
||||
settingsFetcher.submit(
|
||||
{},
|
||||
@@ -122,11 +125,11 @@ function AccountSettingsModal({
|
||||
}
|
||||
type="submit"
|
||||
intent="account"
|
||||
text="Save"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)}
|
||||
className="bg-[#4465DB]"
|
||||
/>
|
||||
<ModalButton
|
||||
text="Close"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$CLOSE)}
|
||||
onClick={onClose}
|
||||
className="bg-[#737373]"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
@@ -7,10 +8,11 @@ import ModalButton from "../buttons/ModalButton";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { CustomInput } from "../form/custom-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function ConnectToGitHubByTokenModal() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBody testID="auth-modal">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -29,13 +31,18 @@ function ConnectToGitHubByTokenModal() {
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[#A3A3A3]">
|
||||
By connecting you agree to our{" "}
|
||||
<span className="text-hyperlink">terms of service</span>.
|
||||
{t(
|
||||
I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$BY_CONNECTING_YOU_AGREE,
|
||||
)}{" "}
|
||||
<span className="text-hyperlink">
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE)}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</label>
|
||||
<ModalButton
|
||||
type="submit"
|
||||
text="Continue"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$CONTINUE)}
|
||||
className="bg-[#791B80] w-full"
|
||||
disabled={navigation.state === "loading"}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import LoadingSpinnerOuter from "#/assets/loading-outer.svg?react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoadingSpinnerOuter from "#/icons/loading-outer.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size: "small" | "large";
|
||||
@@ -28,10 +30,12 @@ interface LoadingProjectModalProps {
|
||||
}
|
||||
|
||||
function LoadingProjectModal({ message }: LoadingProjectModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
<span className="text-xl leading-6 -tracking-[0.01em] font-semibold">
|
||||
{message || "Loading..."}
|
||||
{message || t(I18nKey.LOADING_PROJECT$LOADING)}
|
||||
</span>
|
||||
<LoadingSpinner size="large" />
|
||||
</ModalBody>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { CustomInput } from "../form/custom-input";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "./confirmation-modals/BaseModal";
|
||||
import { clientLoader } from "#/routes/_oh";
|
||||
import { clientAction } from "#/routes/login";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConnectToGitHubModalProps {
|
||||
onClose: () => void;
|
||||
@@ -16,6 +18,7 @@ interface ConnectToGitHubModalProps {
|
||||
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
|
||||
const fetcher = useFetcher<typeof clientAction>({ key: "login" });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
@@ -24,14 +27,14 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
<BaseModalDescription
|
||||
description={
|
||||
<span>
|
||||
Get your token{" "}
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
here
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
@@ -53,14 +56,15 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
testId="connect-to-github"
|
||||
type="submit"
|
||||
text="Connect"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)}
|
||||
disabled={fetcher.state === "submitting"}
|
||||
className="bg-[#791B80] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
onClick={onClose}
|
||||
text="Close"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CLOSE)}
|
||||
className="bg-[#737373] w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SecurityInvariant from "./invariant/Invariant";
|
||||
import BaseModal from "../base-modal/BaseModal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SecurityProps {
|
||||
isOpen: boolean;
|
||||
@@ -17,11 +19,13 @@ const SecurityAnalyzers: Record<SecurityAnalyzerOption, React.ElementType> = {
|
||||
};
|
||||
|
||||
function Security({ isOpen, onOpenChange, securityAnalyzer }: SecurityProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const AnalyzerComponent =
|
||||
securityAnalyzer &&
|
||||
SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
|
||||
? SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
|
||||
: () => <div>Unknown security analyzer chosen</div>;
|
||||
: () => <div>{t(I18nKey.SECURITY$UNKNOWN_ANALYZER_LABEL)}</div>;
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
|
||||
@@ -123,7 +123,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
|
||||
async function exportTraces(): Promise<void> {
|
||||
const data = await request(`/api/security/export-trace`);
|
||||
toast.info("Trace exported");
|
||||
toast.info(t(I18nKey.INVARIANT$TRACE_EXPORTED_MESSAGE));
|
||||
|
||||
const filename = `openhands-trace-${getFormattedDateTime()}.json`;
|
||||
downloadJSON(data, filename);
|
||||
@@ -134,7 +134,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ policy }),
|
||||
});
|
||||
toast.info("Policy updated");
|
||||
toast.info(t(I18nKey.INVARIANT$POLICY_UPDATED_MESSAGE));
|
||||
}
|
||||
|
||||
async function updateSettings(): Promise<void> {
|
||||
@@ -143,7 +143,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
toast.info("Settings updated");
|
||||
toast.info(t(I18nKey.INVARIANT$SETTINGS_UPDATED_MESSAGE));
|
||||
}
|
||||
|
||||
const handleExportTraces = useCallback(() => {
|
||||
@@ -162,9 +162,9 @@ function SecurityInvariant(): JSX.Element {
|
||||
logs: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">Logs</h2>
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$LOG_LABEL)}</h2>
|
||||
<Button onClick={handleExportTraces} className="bg-neutral-700">
|
||||
Export Trace
|
||||
{t(I18nKey.INVARIANT$EXPORT_TRACE_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 p-4 max-h-screen overflow-y-auto" ref={logsRef}>
|
||||
@@ -195,9 +195,9 @@ function SecurityInvariant(): JSX.Element {
|
||||
policy: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">Policy</h2>
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$POLICY_LABEL)}</h2>
|
||||
<Button className="bg-neutral-700" onClick={handleUpdatePolicy}>
|
||||
Update Policy
|
||||
{t(I18nKey.INVARIANT$UPDATE_POLICY_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex grow items-center justify-center">
|
||||
@@ -214,14 +214,16 @@ function SecurityInvariant(): JSX.Element {
|
||||
settings: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">Settings</h2>
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$SETTINGS_LABEL)}</h2>
|
||||
<Button className="bg-neutral-700" onClick={handleUpdateSettings}>
|
||||
Update Settings
|
||||
{t(I18nKey.INVARIANT$UPDATE_SETTINGS_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex grow p-4">
|
||||
<div className="flex flex-col w-full">
|
||||
<p className="mb-2">Ask for user confirmation on risk severity:</p>
|
||||
<p className="mb-2">
|
||||
{t(I18nKey.INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL)}
|
||||
</p>
|
||||
<Select
|
||||
placeholder="Select risk severity"
|
||||
value={selectedRisk}
|
||||
@@ -264,7 +266,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
key={ActionSecurityRisk.HIGH + 1}
|
||||
aria-label="Don't ask for confirmation"
|
||||
>
|
||||
Don't ask for confirmation
|
||||
{t(I18nKey.INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL)}
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -278,18 +280,17 @@ function SecurityInvariant(): JSX.Element {
|
||||
<div className="w-60 bg-neutral-800 border-r border-r-neutral-600 p-4 flex-shrink-0">
|
||||
<div className="text-center mb-2">
|
||||
<InvariantLogoIcon className="mx-auto mb-1" />
|
||||
<b>Invariant Analyzer</b>
|
||||
<b>{t(I18nKey.INVARIANT$INVARIANT_ANALYZER_LABEL)}</b>
|
||||
</div>
|
||||
<p className="text-[0.6rem]">
|
||||
Invariant Analyzer continuously monitors your OpenHands agent for
|
||||
security issues.{" "}
|
||||
{t(I18nKey.INVARIANT$INVARIANT_ANALYZER_MESSAGE)}{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://github.com/invariantlabs-ai/invariant"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Click to learn more
|
||||
{t(I18nKey.INVARIANT$CLICK_TO_LEARN_MORE_LABEL)}
|
||||
</a>
|
||||
</p>
|
||||
<hr className="border-t border-neutral-600 my-2" />
|
||||
@@ -298,19 +299,19 @@ function SecurityInvariant(): JSX.Element {
|
||||
className={`cursor-pointer p-2 rounded ${activeSection === "logs" && "bg-neutral-600"}`}
|
||||
onClick={() => setActiveSection("logs")}
|
||||
>
|
||||
Logs
|
||||
{t(I18nKey.INVARIANT$LOG_LABEL)}
|
||||
</div>
|
||||
<div
|
||||
className={`cursor-pointer p-2 rounded ${activeSection === "policy" && "bg-neutral-600"}`}
|
||||
onClick={() => setActiveSection("policy")}
|
||||
>
|
||||
Policy
|
||||
{t(I18nKey.INVARIANT$POLICY_LABEL)}
|
||||
</div>
|
||||
<div
|
||||
className={`cursor-pointer p-2 rounded ${activeSection === "settings" && "bg-neutral-600"}`}
|
||||
onClick={() => setActiveSection("settings")}
|
||||
>
|
||||
Settings
|
||||
{t(I18nKey.INVARIANT$SETTINGS_LABEL)}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import EllipsisH from "#/assets/ellipsis-h.svg?react";
|
||||
import posthog from "posthog-js";
|
||||
import EllipsisH from "#/icons/ellipsis-h.svg?react";
|
||||
import { ModalBackdrop } from "../modals/modal-backdrop";
|
||||
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
|
||||
import { addUserMessage } from "#/state/chatSlice";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { createChatMessage } from "#/services/chatService";
|
||||
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
|
||||
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
|
||||
import { ProjectMenuDetails } from "./project-menu-details";
|
||||
import { downloadWorkspace } from "#/utils/download-workspace";
|
||||
import { LoadingSpinner } from "../modals/LoadingProject";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
interface ProjectMenuCardProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -25,24 +27,23 @@ export function ProjectMenuCard({
|
||||
isConnectedToGitHub,
|
||||
githubData,
|
||||
}: ProjectMenuCardProps) {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [working, setWorking] = React.useState(false);
|
||||
|
||||
const toggleMenuVisibility = () => {
|
||||
setContextMenuIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handlePushToGitHub = () => {
|
||||
posthog.capture("push_to_github_button_clicked");
|
||||
const rawEvent = {
|
||||
content: `
|
||||
Let's push the code to GitHub.
|
||||
If we're currently on the openhands-workspace branch, please create a new branch with a descriptive name.
|
||||
Commit any changes and push them to the remote repository.
|
||||
Finally, open up a pull request using the GitHub API and the token in the GITHUB_TOKEN environment variable, then show me the URL of the pull request.
|
||||
Please push the changes to GitHub and open a pull request.
|
||||
`,
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -58,20 +59,27 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
|
||||
setContextMenuIsOpen(false);
|
||||
};
|
||||
|
||||
const handleDownloadWorkspace = () => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
try {
|
||||
setWorking(true);
|
||||
downloadWorkspace().then(
|
||||
() => setWorking(false),
|
||||
() => setWorking(false),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
|
||||
{contextMenuIsOpen && (
|
||||
{!working && contextMenuIsOpen && (
|
||||
<ProjectMenuCardContextMenu
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
onPushToGitHub={handlePushToGitHub}
|
||||
onDownloadWorkspace={() => {
|
||||
try {
|
||||
downloadWorkspace();
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
}}
|
||||
onDownloadWorkspace={handleDownloadWorkspace}
|
||||
onClose={() => setContextMenuIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -93,7 +101,11 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
|
||||
onClick={toggleMenuVisibility}
|
||||
aria-label="Open project menu"
|
||||
>
|
||||
<EllipsisH width={36} height={36} />
|
||||
{working ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
<EllipsisH width={36} height={36} />
|
||||
)}
|
||||
</button>
|
||||
{connectToGitHubModalOpen && (
|
||||
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import CloudConnection from "#/assets/cloud-connection.svg?react";
|
||||
import CloudConnection from "#/icons/cloud-connection.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsPlaceholderProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -10,9 +12,13 @@ export function ProjectMenuDetailsPlaceholder({
|
||||
isConnectedToGitHub,
|
||||
onConnectToGitHub,
|
||||
}: ProjectMenuDetailsPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm leading-6 font-semibold">New Project</span>
|
||||
<span className="text-sm leading-6 font-semibold">
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnectToGitHub}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import ExternalLinkIcon from "#/assets/external-link.svg?react";
|
||||
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;
|
||||
@@ -12,6 +14,7 @@ export function ProjectMenuDetails({
|
||||
avatar,
|
||||
lastCommit,
|
||||
}: ProjectMenuDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
@@ -32,7 +35,8 @@ export function ProjectMenuDetails({
|
||||
>
|
||||
<span>{lastCommit.sha.slice(-7)}</span> <span>·</span>{" "}
|
||||
<span>
|
||||
{formatTimeDelta(new Date(lastCommit.commit.author.date))} ago
|
||||
{formatTimeDelta(new Date(lastCommit.commit.author.date))}{" "}
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS$AGO_LABEL)}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClickOutsideElement } from "#/hooks/useClickOutsideElement";
|
||||
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;
|
||||
@@ -18,7 +20,7 @@ export function ProjectMenuCardContextMenu({
|
||||
onClose,
|
||||
}: ProjectMenuCardContextMenuProps) {
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
@@ -26,16 +28,16 @@ export function ProjectMenuCardContextMenu({
|
||||
>
|
||||
{!isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onConnectToGitHub}>
|
||||
Connect to GitHub
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onPushToGitHub}>
|
||||
Push to GitHub
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
<ContextMenuListItem onClick={onDownloadWorkspace}>
|
||||
Download as .zip
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ArrowSendIcon from "#/assets/arrow-send.svg?react";
|
||||
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
|
||||
|
||||
interface ScrollToBottomButtonProps {
|
||||
onClick: () => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Lightbulb from "#/assets/lightbulb.svg?react";
|
||||
import Refresh from "#/assets/refresh.svg?react";
|
||||
import Lightbulb from "#/icons/lightbulb.svg?react";
|
||||
import Refresh from "#/icons/refresh.svg?react";
|
||||
|
||||
interface SuggestionBubbleProps {
|
||||
suggestion: string;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export type Suggestion = { label: string; value: string };
|
||||
|
||||
interface SuggestionItemProps {
|
||||
suggestion: Suggestion;
|
||||
onClick: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
|
||||
return (
|
||||
<li className="border border-neutral-600 rounded-xl hover:bg-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="suggestion"
|
||||
onClick={() => onClick(suggestion.value)}
|
||||
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-4 font-semibold"
|
||||
>
|
||||
{suggestion.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SuggestionItem, type Suggestion } from "./suggestion-item";
|
||||
|
||||
interface SuggestionsProps {
|
||||
suggestions: Suggestion[];
|
||||
onSuggestionClick: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Suggestions({
|
||||
suggestions,
|
||||
onSuggestionClick,
|
||||
}: SuggestionsProps) {
|
||||
return (
|
||||
<ul data-testid="suggestions" className="flex flex-col gap-4 w-full">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<SuggestionItem
|
||||
key={index}
|
||||
suggestion={suggestion}
|
||||
onClick={onSuggestionClick}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Clip from "#/assets/clip.svg?react";
|
||||
import Clip from "#/icons/clip.svg?react";
|
||||
|
||||
interface UploadImageInputProps {
|
||||
onUpload: (files: File[]) => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoadingSpinner } from "./modals/LoadingProject";
|
||||
import DefaultUserAvatar from "#/assets/default-user.svg?react";
|
||||
import DefaultUserAvatar from "#/icons/default-user.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface UserAvatarProps {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import React from "react";
|
||||
import { Data } from "ws";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
|
||||
interface WebSocketClientOptions {
|
||||
token: string | null;
|
||||
onOpen?: (event: Event) => void;
|
||||
onMessage?: (event: MessageEvent<Data>) => void;
|
||||
onError?: (event: Event) => void;
|
||||
onClose?: (event: Event) => void;
|
||||
}
|
||||
|
||||
interface WebSocketContextType {
|
||||
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
|
||||
start: (options?: WebSocketClientOptions) => void;
|
||||
stop: () => void;
|
||||
setRuntimeIsInitialized: () => void;
|
||||
runtimeActive: boolean;
|
||||
isConnected: boolean;
|
||||
events: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const SocketContext = React.createContext<WebSocketContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface SocketProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SocketProvider({ children }: SocketProviderProps) {
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
const [runtimeActive, setRuntimeActive] = React.useState(false);
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const setRuntimeIsInitialized = () => {
|
||||
setRuntimeActive(true);
|
||||
};
|
||||
|
||||
const start = React.useCallback((options?: WebSocketClientOptions): void => {
|
||||
if (wsRef.current) {
|
||||
EventLogger.warning(
|
||||
"WebSocket connection is already established, but a new one is starting anyways.",
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const sessionToken = options?.token || "NO_JWT"; // not allowed to be empty or duplicated
|
||||
const ghToken = localStorage.getItem("ghToken") || "NO_GITHUB";
|
||||
|
||||
const ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
|
||||
"openhands",
|
||||
sessionToken,
|
||||
ghToken,
|
||||
]);
|
||||
|
||||
ws.addEventListener("open", (event) => {
|
||||
setIsConnected(true);
|
||||
options?.onOpen?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
EventLogger.message(event);
|
||||
|
||||
setEvents((prevEvents) => [...prevEvents, JSON.parse(event.data)]);
|
||||
options?.onMessage?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
EventLogger.event(event, "SOCKET ERROR");
|
||||
options?.onError?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
EventLogger.event(event, "SOCKET CLOSE");
|
||||
|
||||
setIsConnected(false);
|
||||
setRuntimeActive(false);
|
||||
wsRef.current = null;
|
||||
options?.onClose?.(event);
|
||||
});
|
||||
|
||||
wsRef.current = ws;
|
||||
}, []);
|
||||
|
||||
const stop = React.useCallback((): void => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const send = React.useCallback(
|
||||
(data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
|
||||
if (!wsRef.current) {
|
||||
EventLogger.error("WebSocket is not connected.");
|
||||
return;
|
||||
}
|
||||
setEvents((prevEvents) => [...prevEvents, JSON.parse(data.toString())]);
|
||||
wsRef.current.send(data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
setRuntimeIsInitialized,
|
||||
runtimeActive,
|
||||
isConnected,
|
||||
events,
|
||||
}),
|
||||
[
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
setRuntimeIsInitialized,
|
||||
runtimeActive,
|
||||
isConnected,
|
||||
events,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useSocket() {
|
||||
const context = React.useContext(SocketContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { SocketProvider, useSocket };
|
||||
@@ -0,0 +1,196 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { Settings } from "#/services/settings";
|
||||
import ActionType from "#/types/ActionType";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
|
||||
const RECONNECT_RETRIES = 5;
|
||||
|
||||
export enum WsClientProviderStatus {
|
||||
STOPPED,
|
||||
OPENING,
|
||||
ACTIVE,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
interface UseWsClient {
|
||||
status: WsClientProviderStatus;
|
||||
events: Record<string, unknown>[];
|
||||
send: (event: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const WsClientContext = React.createContext<UseWsClient>({
|
||||
status: WsClientProviderStatus.STOPPED,
|
||||
events: [],
|
||||
send: () => {
|
||||
throw new Error("not connected");
|
||||
},
|
||||
});
|
||||
|
||||
interface WsClientProviderProps {
|
||||
enabled: boolean;
|
||||
token: string | null;
|
||||
ghToken: string | null;
|
||||
settings: Settings | null;
|
||||
}
|
||||
|
||||
export function WsClientProvider({
|
||||
enabled,
|
||||
token,
|
||||
ghToken,
|
||||
settings,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
const tokenRef = React.useRef<string | null>(token);
|
||||
const ghTokenRef = React.useRef<string | null>(ghToken);
|
||||
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
const [retryCount, setRetryCount] = React.useState(RECONNECT_RETRIES);
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
if (!wsRef.current) {
|
||||
EventLogger.error("WebSocket is not connected.");
|
||||
return;
|
||||
}
|
||||
wsRef.current.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
setRetryCount(RECONNECT_RETRIES);
|
||||
setStatus(WsClientProviderStatus.OPENING);
|
||||
const initEvent = {
|
||||
action: ActionType.INIT,
|
||||
args: settings,
|
||||
};
|
||||
send(initEvent);
|
||||
}
|
||||
|
||||
function handleMessage(messageEvent: MessageEvent) {
|
||||
const event = JSON.parse(messageEvent.data);
|
||||
setEvents((prevEvents) => [...prevEvents, event]);
|
||||
if (event.extras?.agent_state === AgentState.INIT) {
|
||||
setStatus(WsClientProviderStatus.ACTIVE);
|
||||
}
|
||||
if (
|
||||
status !== WsClientProviderStatus.ACTIVE &&
|
||||
event?.observation === "error"
|
||||
) {
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
}
|
||||
|
||||
handleAssistantMessage(event);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (retryCount) {
|
||||
setTimeout(() => {
|
||||
setRetryCount(retryCount - 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
setStatus(WsClientProviderStatus.STOPPED);
|
||||
setEvents([]);
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
function handleError(event: Event) {
|
||||
posthog.capture("socket_error");
|
||||
EventLogger.event(event, "SOCKET ERROR");
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
}
|
||||
|
||||
// Connect websocket
|
||||
React.useEffect(() => {
|
||||
let ws = wsRef.current;
|
||||
|
||||
// If disabled close any existing websockets...
|
||||
if (!enabled || !retryCount) {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
wsRef.current = null;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// If there is no websocket or the tokens have changed or the current websocket is closed,
|
||||
// create a new one
|
||||
if (
|
||||
!ws ||
|
||||
(tokenRef.current && token !== tokenRef.current) ||
|
||||
ghToken !== ghTokenRef.current ||
|
||||
ws.readyState === WebSocket.CLOSED ||
|
||||
ws.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
ws?.close();
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
let wsUrl = `${protocol}//${baseUrl}/ws`;
|
||||
if (events.length) {
|
||||
wsUrl += `?latest_event_id=${events[events.length - 1].id}`;
|
||||
}
|
||||
ws = new WebSocket(wsUrl, [
|
||||
"openhands",
|
||||
token || "NO_JWT",
|
||||
ghToken || "NO_GITHUB",
|
||||
]);
|
||||
}
|
||||
ws.addEventListener("open", handleOpen);
|
||||
ws.addEventListener("message", handleMessage);
|
||||
ws.addEventListener("error", handleError);
|
||||
ws.addEventListener("close", handleClose);
|
||||
wsRef.current = ws;
|
||||
tokenRef.current = token;
|
||||
ghTokenRef.current = ghToken;
|
||||
|
||||
return () => {
|
||||
ws.removeEventListener("open", handleOpen);
|
||||
ws.removeEventListener("message", handleMessage);
|
||||
ws.removeEventListener("error", handleError);
|
||||
ws.removeEventListener("close", handleClose);
|
||||
};
|
||||
}, [enabled, token, ghToken, retryCount]);
|
||||
|
||||
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
|
||||
// before actually closing the socket and cancel the operation if the component gets remounted.
|
||||
React.useEffect(() => {
|
||||
const timeout = closeRef.current;
|
||||
if (timeout != null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return () => {
|
||||
closeRef.current = setTimeout(() => {
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
ws.removeEventListener("close", handleClose);
|
||||
ws.close();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo<UseWsClient>(
|
||||
() => ({
|
||||
status,
|
||||
events,
|
||||
send,
|
||||
}),
|
||||
[status, events],
|
||||
);
|
||||
|
||||
return (
|
||||
<WsClientContext.Provider value={value}>
|
||||
{children}
|
||||
</WsClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWsClient() {
|
||||
const context = React.useContext(WsClientContext);
|
||||
return context;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user