Compare commits

..

1 Commits

Author SHA1 Message Date
openhands
48cad4117a Fix issue #5257: Display API costs in frontend 2024-11-25 14:25:10 +00:00
139 changed files with 3252 additions and 6053 deletions

View File

@@ -1,4 +1,4 @@
name: Run SWE-Bench Evaluation
name: Run Evaluation
on:
pull_request:
@@ -58,6 +58,24 @@ jobs:
echo "api_key = \"$DEEPSEEK_API_KEY\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation
env:
ALLHANDS_API_KEY: ${{ secrets.ALLHANDS_EVAL_RUNTIME_API_KEY }}
RUNTIME: remote
SANDBOX_REMOTE_RUNTIME_API_URL: https://runtime.eval.all-hands.dev
EVAL_DOCKER_IMAGE_PREFIX: us-central1-docker.pkg.dev/evaluation-092424/swe-bench-images
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' $N_PROCESSES
# get evaluation report
REPORT_FILE=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/deepseek-chat_maxiter_10_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE: $REPORT_FILE"
echo "INTEGRATION_TEST_REPORT<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Run SWE-Bench evaluation
env:
ALLHANDS_API_KEY: ${{ secrets.ALLHANDS_EVAL_RUNTIME_API_KEY }}
@@ -125,6 +143,9 @@ jobs:
**SWE-Bench Evaluation Report**
${{ env.SWEBENCH_REPORT }}
---
**Integration Tests Evaluation Report**
${{ env.INTEGRATION_TEST_REPORT }}
---
You can download the full evaluation outputs [here](${{ env.ARTIFACT_URL }}).
- name: Post to a Slack channel

View File

@@ -1,158 +0,0 @@
name: Run Integration Tests
on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual trigger'
required: true
default: ''
schedule:
- cron: '30 22 * * *' # Runs at 10:30pm UTC every day
env:
N_PROCESSES: 10 # Global configuration for number of parallel processes for evaluation
jobs:
run-integration-tests:
if: github.event.label.name == 'integration-test' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
contents: "read"
id-token: "write"
pull-requests: "write"
issues: "write"
strategy:
matrix:
python-version: ["3.12"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Comment on PR if 'integration-test' label is present
if: github.event_name == 'pull_request' && github.event.label.name == 'integration-test'
uses: KeisukeYamashita/create-comment@v1
with:
unique: false
comment: |
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
- name: Install Python dependencies using Poetry
run: poetry install --without evaluation,llama-index
- name: Configure config.toml for testing with Haiku
env:
LLM_MODEL: "litellm_proxy/claude-3-5-haiku-20241022"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Build environment
run: make build
- name: Run integration test evaluation for Haiku
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' $N_PROCESSES '' 'haiku_run'
# get integration tests report
REPORT_FILE_HAIKU=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/*haiku*_maxiter_10_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE: $REPORT_FILE_HAIKU"
echo "INTEGRATION_TEST_REPORT_HAIKU<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_HAIKU >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Wait a little bit
run: sleep 10
- name: Configure config.toml for testing with DeepSeek
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for DeepSeek
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' $N_PROCESSES '' 'deepseek_run'
# get integration tests report
REPORT_FILE_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/deepseek*_maxiter_10_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE: $REPORT_FILE_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create archive of evaluation outputs
run: |
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* # Only include the actual result directories
- name: Upload evaluation results as artifact
uses: actions/upload-artifact@v4
id: upload_results_artifact
with:
name: integration-test-outputs-${{ github.run_id }}-${{ github.run_attempt }}
path: integration_tests_*.tar.gz
- name: Get artifact URLs
run: |
echo "ARTIFACT_URL=${{ steps.upload_results_artifact.outputs.artifact-url }}" >> $GITHUB_ENV
- name: Set timestamp and trigger reason
run: |
echo "TIMESTAMP=$(date +'%Y-%m-%d-%H-%M')" >> $GITHUB_ENV
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "TRIGGER_REASON=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "TRIGGER_REASON=manual-${{ github.event.inputs.reason }}" >> $GITHUB_ENV
else
echo "TRIGGER_REASON=nightly-scheduled" >> $GITHUB_ENV
fi
- name: Comment with results and artifact link
id: create_comment
uses: KeisukeYamashita/create-comment@v1
with:
# if triggered by PR, use PR number, otherwise use 5077 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 5077 }}
unique: false
comment: |
Trigger by: ${{ github.event_name == 'pull_request' && format('Pull Request (integration-test label on PR #{0})', github.event.pull_request.number) || (github.event_name == 'workflow_dispatch' && format('Manual Trigger: {0}', github.event.inputs.reason)) || 'Nightly Scheduled Run' }}
Commit: ${{ github.sha }}
**Integration Tests Report (Haiku)**
Haiku LLM Test Results:
${{ env.INTEGRATION_TEST_REPORT_HAIKU }}
---
**Integration Tests Report (DeepSeek)**
DeepSeek LLM Test Results:
${{ env.INTEGRATION_TEST_REPORT_DEEPSEEK }}
---
Download evaluation outputs (includes both Haiku and DeepSeek results): [Download](${{ steps.upload_results_artifact.outputs.artifact-url }})

View File

@@ -184,6 +184,10 @@ test:
@$(MAKE) -s test-frontend
build-frontend:
@echo "$(YELLOW)Cleaning TypeScript build cache...$(RESET)"
@cd frontend && npx tsc --build --clean
@echo "$(YELLOW)Cleaning Git cache for casing issues...$(RESET)"
@cd frontend && git rm -r --cached . && git add . && git commit -m "Fix Git cache" || echo "No changes to commit"
@echo "$(YELLOW)Building frontend...$(RESET)"
@cd frontend && npm run build

View File

@@ -1,459 +0,0 @@
---
id: configuration-options
title: Configuration Options
sidebar_label: Configuration Options
---
# Configuration Options
This guide details all configuration options available for OpenHands, helping you customize its behavior and integrate it with other services.
---
# Table of Contents
1. [Core Configuration](#core-configuration)
- [API Keys](#api-keys)
- [Workspace](#workspace)
- [Debugging and Logging](#debugging-and-logging)
- [Session Management](#session-management)
- [Trajectories](#trajectories)
- [File Store](#file-store)
- [Task Management](#task-management)
- [Sandbox Configuration](#sandbox-configuration)
- [Miscellaneous](#miscellaneous)
2. [LLM Configuration](#llm-configuration)
- [AWS Credentials](#aws-credentials)
- [API Configuration](#api-configuration)
- [Custom LLM Provider](#custom-llm-provider)
- [Embeddings](#embeddings)
- [Message Handling](#message-handling)
- [Model Selection](#model-selection)
- [Retrying](#retrying)
- [Advanced Options](#advanced-options)
3. [Agent Configuration](#agent-configuration)
- [Microagent Configuration](#microagent-configuration)
- [Memory Configuration](#memory-configuration)
- [LLM Configuration](#llm-configuration-2)
- [ActionSpace Configuration](#actionspace-configuration)
- [Microagent Usage](#microagent-usage)
4. [Sandbox Configuration](#sandbox-configuration-2)
- [Execution](#execution)
- [Container Image](#container-image)
- [Networking](#networking)
- [Linting and Plugins](#linting-and-plugins)
- [Dependencies and Environment](#dependencies-and-environment)
- [Evaluation](#evaluation)
5. [Security Configuration](#security-configuration)
- [Confirmation Mode](#confirmation-mode)
- [Security Analyzer](#security-analyzer)
---
## Core Configuration
The core configuration options are defined in the `[core]` section of the `config.toml` file.
**API Keys**
- `e2b_api_key`
- Type: `str`
- Default: `""`
- Description: API key for E2B
- `modal_api_token_id`
- Type: `str`
- Default: `""`
- Description: API token ID for Modal
- `modal_api_token_secret`
- Type: `str`
- Default: `""`
- Description: API token secret for Modal
**Workspace**
- `workspace_base`
- Type: `str`
- Default: `"./workspace"`
- Description: Base path for the workspace
- `cache_dir`
- Type: `str`
- Default: `"/tmp/cache"`
- Description: Cache directory path
**Debugging and Logging**
- `debug`
- Type: `bool`
- Default: `false`
- Description: Enable debugging
- `disable_color`
- Type: `bool`
- Default: `false`
- Description: Disable color in terminal output
**Trajectories**
- `trajectories_path`
- Type: `str`
- Default: `"./trajectories"`
- Description: Path to store trajectories (can be a folder or a file). If it's a folder, the trajectories will be saved in a file named with the session id name and .json extension, in that folder.
**File Store**
- `file_store_path`
- Type: `str`
- Default: `"/tmp/file_store"`
- Description: File store path
- `file_store`
- Type: `str`
- Default: `"memory"`
- Description: File store type
- `file_uploads_allowed_extensions`
- Type: `list of str`
- Default: `[".*"]`
- Description: List of allowed file extensions for uploads
- `file_uploads_max_file_size_mb`
- Type: `int`
- Default: `0`
- Description: Maximum file size for uploads, in megabytes
- `file_uploads_restrict_file_types`
- Type: `bool`
- Default: `false`
- Description: Restrict file types for file uploads
- `file_uploads_allowed_extensions`
- Type: `list of str`
- Default: `[".*"]`
- Description: List of allowed file extensions for uploads
**Task Management**
- `max_budget_per_task`
- Type: `float`
- Default: `0.0`
- Description: Maximum budget per task (0.0 means no limit)
- `max_iterations`
- Type: `int`
- Default: `100`
- Description: Maximum number of iterations
**Sandbox Configuration**
- `workspace_mount_path_in_sandbox`
- Type: `str`
- Default: `"/workspace"`
- Description: Path to mount the workspace in the sandbox
- `workspace_mount_path`
- Type: `str`
- Default: `""`
- Description: Path to mount the workspace
- `workspace_mount_rewrite`
- Type: `str`
- Default: `""`
- Description: Path to rewrite the workspace mount path to. You can usually ignore this, it refers to special cases of running inside another container.
**Miscellaneous**
- `run_as_openhands`
- Type: `bool`
- Default: `true`
- Description: Run as OpenHands
- `runtime`
- Type: `str`
- Default: `"eventstream"`
- Description: Runtime environment
- `default_agent`
- Type: `str`
- Default: `"CodeActAgent"`
- Description: Name of the default agent
- `jwt_secret`
- Type: `str`
- Default: `uuid.uuid4().hex`
- Description: JWT secret for authentication. Please set it to your own value.
## LLM Configuration
The LLM (Large Language Model) configuration options are defined in the `[llm]` section of the `config.toml` file.
**AWS Credentials**
- `aws_access_key_id`
- Type: `str`
- Default: `""`
- Description: AWS access key ID
- `aws_region_name`
- Type: `str`
- Default: `""`
- Description: AWS region name
- `aws_secret_access_key`
- Type: `str`
- Default: `""`
- Description: AWS secret access key
**API Configuration**
- `api_key`
- Type: `str`
- Default: `None`
- Description: API key to use
- `base_url`
- Type: `str`
- Default: `""`
- Description: API base URL
- `api_version`
- Type: `str`
- Default: `""`
- Description: API version
- `input_cost_per_token`
- Type: `float`
- Default: `0.0`
- Description: Cost per input token
- `output_cost_per_token`
- Type: `float`
- Default: `0.0`
- Description: Cost per output token
**Custom LLM Provider**
- `custom_llm_provider`
- Type: `str`
- Default: `""`
- Description: Custom LLM provider
**Embeddings**
- `embedding_base_url`
- Type: `str`
- Default: `""`
- Description: Embedding API base URL
- `embedding_deployment_name`
- Type: `str`
- Default: `""`
- Description: Embedding deployment name
- `embedding_model`
- Type: `str`
- Default: `"local"`
- Description: Embedding model to use
**Message Handling**
- `max_message_chars`
- Type: `int`
- Default: `30000`
- Description: The approximate maximum number of characters in the content of an event included in the prompt to the LLM. Larger observations are truncated.
- `max_input_tokens`
- Type: `int`
- Default: `0`
- Description: Maximum number of input tokens
- `max_output_tokens`
- Type: `int`
- Default: `0`
- Description: Maximum number of output tokens
**Model Selection**
- `model`
- Type: `str`
- Default: `"claude-3-5-sonnet-20241022"`
- Description: Model to use
**Retrying**
- `num_retries`
- Type: `int`
- Default: `8`
- Description: Number of retries to attempt
- `retry_max_wait`
- Type: `int`
- Default: `120`
- Description: Maximum wait time (in seconds) between retry attempts
- `retry_min_wait`
- Type: `int`
- Default: `15`
- Description: Minimum wait time (in seconds) between retry attempts
- `retry_multiplier`
- Type: `float`
- Default: `2.0`
- Description: Multiplier for exponential backoff calculation
**Advanced Options**
- `drop_params`
- Type: `bool`
- Default: `false`
- Description: Drop any unmapped (unsupported) params without causing an exception
- `caching_prompt`
- Type: `bool`
- Default: `true`
- Description: Using the prompt caching feature if provided by the LLM and supported
- `ollama_base_url`
- Type: `str`
- Default: `""`
- Description: Base URL for the OLLAMA API
- `temperature`
- Type: `float`
- Default: `0.0`
- Description: Temperature for the API
- `timeout`
- Type: `int`
- Default: `0`
- Description: Timeout for the API
- `top_p`
- Type: `float`
- Default: `1.0`
- Description: Top p for the API
- `disable_vision`
- Type: `bool`
- Default: `None`
- Description: If model is vision capable, this option allows to disable image processing (useful for cost reduction)
## Agent Configuration
The agent configuration options are defined in the `[agent]` and `[agent.<agent_name>]` sections of the `config.toml` file.
**Microagent Configuration**
- `micro_agent_name`
- Type: `str`
- Default: `""`
- Description: Name of the micro agent to use for this agent
**Memory Configuration**
- `memory_enabled`
- Type: `bool`
- Default: `false`
- Description: Whether long-term memory (embeddings) is enabled
- `memory_max_threads`
- Type: `int`
- Default: `3`
- Description: The maximum number of threads indexing at the same time for embeddings
**LLM Configuration**
- `llm_config`
- Type: `str`
- Default: `'your-llm-config-group'`
- Description: The name of the LLM config to use
**ActionSpace Configuration**
- `function_calling`
- Type: `bool`
- Default: `true`
- Description: Whether function calling is enabled
- `codeact_enable_browsing`
- Type: `bool`
- Default: `false`
- Description: Whether browsing delegate is enabled in the action space (only works with function calling)
- `codeact_enable_llm_editor`
- Type: `bool`
- Default: `false`
- Description: Whether LLM editor is enabled in the action space (only works with function calling)
- `codeact_enable_jupyter`
- Type: `bool`
- Default: `false`
- Description: Whether Jupyter is enabled in the action space
**Microagent Usage**
- `use_microagents`
- Type: `bool`
- Default: `true`
- Description: Whether to use microagents at all
- `disabled_microagents`
- Type: `list of str`
- Default: `None`
- Description: A list of microagents to disable
## Sandbox Configuration
The sandbox configuration options are defined in the `[sandbox]` section of the `config.toml` file.
**Execution**
- `timeout`
- Type: `int`
- Default: `120`
- Description: Sandbox timeout in seconds
- `user_id`
- Type: `int`
- Default: `1000`
- Description: Sandbox user ID
**Container Image**
- `base_container_image`
- Type: `str`
- Default: `"nikolaik/python-nodejs:python3.12-nodejs22"`
- Description: Container image to use for the sandbox
**Networking**
- `use_host_network`
- Type: `bool`
- Default: `false`
- Description: Use host network
**Linting and Plugins**
- `enable_auto_lint`
- Type: `bool`
- Default: `false`
- Description: Enable auto linting after editing
- `initialize_plugins`
- Type: `bool`
- Default: `true`
- Description: Whether to initialize plugins
**Dependencies and Environment**
- `runtime_extra_deps`
- Type: `str`
- Default: `""`
- Description: Extra dependencies to install in the runtime image
- `runtime_startup_env_vars`
- Type: `dict`
- Default: `{}`
- Description: Environment variables to set at the launch of the runtime
**Evaluation**
- `browsergym_eval_env`
- Type: `str`
- Default: `""`
- Description: BrowserGym environment to use for evaluation
## Security Configuration
The security configuration options are defined in the `[security]` section of the `config.toml` file.
**Confirmation Mode**
- `confirmation_mode`
- Type: `bool`
- Default: `false`
- Description: Enable confirmation mode
**Security Analyzer**
- `security_analyzer`
- Type: `str`
- Default: `""`
- Description: The security analyzer to use
---
> **Note**: Adjust configurations carefully, especially for memory, security, and network-related settings to ensure optimal performance and security.
Please note that the configuration options may be subject to change in future versions of OpenHands. It's recommended to refer to the official documentation for the most up-to-date information.

View File

@@ -44,17 +44,6 @@ const sidebars: SidebarsConfig = {
},
],
},
{
type: 'category',
label: 'Configuration Options',
items: [
{
type: 'doc',
label: 'Overview',
id: 'usage/configuration-options',
},
],
},
{
type: 'category',
label: 'Advanced Configuration',

Binary file not shown.

View File

@@ -6,9 +6,9 @@ This folder contains code and resources to run experiments and evaluations.
### Setup
Before starting evaluation, follow the instructions [here](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md) to setup your local development environment and LLM.
Before starting evaluation, follow the instructions here [here](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md) to setup your local development environment and LLM.
Once you are done with setup, you can follow the benchmark-specific instructions in each subdirectory of the [evaluation directory](#supported-benchmarks).
Once you are done with setup, you can follow the benchmark-specific instructions in each subdirectory of the evaluation directory.
Generally these will involve running `run_infer.py` to perform inference with the agents.
### Implementing and Evaluating an Agent
@@ -42,7 +42,7 @@ temperature = 0.0
## Supported Benchmarks
The OpenHands evaluation harness supports a wide variety of benchmarks across [software engineering](#software-engineering), [web browsing](#web-browsing), and [miscellaneous assistance](#misc-assistance) tasks.
The OpenHands evaluation harness supports a wide variety of benchmarks across software engineering, web browsing, and miscellaneous assistance tasks.
### Software Engineering
@@ -83,7 +83,7 @@ You can start your own fork of [our huggingface evaluation outputs](https://hugg
To learn more about how to integrate your benchmark into OpenHands, check out [tutorial here](https://docs.all-hands.dev/modules/usage/how-to/evaluation-harness). Briefly,
- Each subfolder contains a specific benchmark or experiment. For example, [`evaluation/benchmarks/swe_bench`](./benchmarks/swe_bench) should contain
- Each subfolder contains a specific benchmark or experiment. For example, `evaluation/benchmarks/swe_bench` should contain
all the preprocessing/evaluation/analysis scripts.
- Raw data and experimental records should not be stored within this repo.
- For model outputs, they should be stored at [this huggingface space](https://huggingface.co/spaces/OpenHands/evaluation) for visualization.

View File

@@ -36,21 +36,3 @@ You can update the arguments in the script `evaluation/benchmarks/agent_bench/sc
```bash
./evaluation/benchmarks/agent_bench/scripts/run_infer.sh eval_gpt35_turbo HEAD CodeActAgent 1
```
## Run with Remote Runtime (experimental)
You can run the evaluation using a remote runtime instead of a local Docker container. This is useful when you want to run the evaluation in a cloud environment or when you don't have Docker installed locally.
To use the remote runtime, set the following environment variables:
```bash
# Required environment variables
export ALLHANDS_API_KEY="your-api-key" # Contact the team to get an API key
export RUNTIME=remote
export SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev"
# Run the evaluation
./evaluation/benchmarks/agent_bench/scripts/run_infer.sh llm.eval_gpt4_1106_preview HEAD CodeActAgent 1
```
The remote runtime will build a container image and run the evaluation in a cloud environment. The results will be saved locally in the same way as when running with a local runtime.

View File

@@ -43,16 +43,12 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'eventstream'),
runtime='eventstream',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-slim',
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
),
# do not mount workspace
workspace_base=None,

View File

@@ -48,19 +48,13 @@ def get_config(
# use default base_container_image
enable_auto_lint=True,
use_host_network=False,
timeout=300,
# Add platform to the sandbox config to solve issue 4401
platform='linux/amd64',
timeout=100,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
),
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
# debug
debug=True,
)
config.set_llm_config(
update_llm_config_for_completions_logging(
@@ -113,37 +107,31 @@ def process_instance(
# =============================================
# create sandbox and run the agent
# =============================================
runtime: Runtime = create_runtime(config)
call_async_from_sync(runtime.connect)
try:
test_class.initialize_runtime(runtime)
# Here's how you can run the agent (similar to the `main` function) and get the final task state
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=MessageAction(content=instruction),
runtime=runtime,
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
)
test_class.initialize_runtime(runtime)
# Here's how you can run the agent (similar to the `main` function) and get the final task state
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=MessageAction(content=instruction),
runtime=runtime,
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
)
if state is None:
raise ValueError('State should not be None.')
)
if state is None:
raise ValueError('State should not be None.')
# # =============================================
# # result evaluation
# # =============================================
# # =============================================
# # result evaluation
# # =============================================
histories = state.history
# some basic check
logger.info(f'Total events in history: {len(histories)}')
assert len(histories) > 0, 'History should not be empty'
test_result: TestResult = test_class.verify_result(runtime, histories)
metrics = state.metrics.get() if state.metrics else None
finally:
runtime.close()
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
# Save the output
output = EvalOutput(
@@ -151,7 +139,7 @@ def process_instance(
instance=instance.to_dict(),
instruction=instruction,
metadata=metadata,
history=[event_to_dict(event) for event in histories],
history=histories,
metrics=metrics,
error=state.last_error if state and state.last_error else None,
test_result=test_result.model_dump(),

View File

@@ -108,8 +108,6 @@ class Test(BaseIntegrationTest):
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
from openhands.core.logger import openhands_logger as logger
# check if the "The answer is OpenHands is all you need!" is in any message
message_actions = [
event
@@ -118,29 +116,19 @@ class Test(BaseIntegrationTest):
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
)
]
logger.debug(f'Total message-like events: {len(message_actions)}')
for event in message_actions:
try:
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
elif isinstance(event, MessageAction):
content = event.content
else:
logger.warning(f'Unexpected event type: {type(event)}')
continue
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
elif isinstance(event, MessageAction):
content = event.content
else:
raise ValueError(f'Unknown event type: {type(event)}')
if 'OpenHands is all you need!' in content:
return TestResult(success=True)
except Exception as e:
logger.error(f'Error processing event: {e}')
logger.debug(
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
)
if 'OpenHands is all you need!' in content:
return TestResult(success=True)
return TestResult(
success=False,
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}. Messages: {message_actions}',
)

View File

@@ -14,9 +14,7 @@ class Test(BaseIntegrationTest):
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
from openhands.core.logger import openhands_logger as logger
# check if the license information is in any message
# check if the "The answer is OpenHands is all you need!" is in any message
message_actions = [
event
for event in histories
@@ -24,35 +22,23 @@ class Test(BaseIntegrationTest):
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
)
]
logger.info(f'Total message-like events: {len(message_actions)}')
for event in message_actions:
try:
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
if event.thought:
content += f'\n\n{event.thought}'
elif isinstance(event, MessageAction):
content = event.content
else:
logger.warning(f'Unexpected event type: {type(event)}')
continue
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
elif isinstance(event, MessageAction):
content = event.content
else:
raise ValueError(f'Unknown event type: {type(event)}')
if (
'non-commercial' in content
or 'MIT' in content
or 'Apache 2.0' in content
):
return TestResult(success=True)
except Exception as e:
logger.error(f'Error processing event: {e}')
logger.debug(
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
)
if (
'non-commercial' in content
or 'MIT' in content
or 'Apache 2.0' in content
):
return TestResult(success=True)
return TestResult(
success=False,
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}. Messages: {message_actions}',
)

View File

@@ -2,11 +2,10 @@ 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 { addUserMessage } from "#/state/chat-slice";
import { SUGGESTIONS } from "#/utils/suggestions";
import * as ChatSlice from "#/state/chat-slice";
import { WsClientProviderStatus } from "#/context/ws-client-provider";
import { ChatInterface } from "#/routes/_oh.app/chat-interface";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
@@ -18,11 +17,7 @@ describe("Empty state", () => {
}));
const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
useWsClient: vi.fn(() => ({
send: sendMock,
status: WsClientProviderStatus.ACTIVE,
isLoadingMessages: false,
})),
useWsClient: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
}));
beforeAll(() => {
@@ -89,8 +84,7 @@ describe("Empty state", () => {
// this is to test that the message is in the UI before the socket is called
useWsClientMock.mockImplementation(() => ({
send: sendMock,
status: WsClientProviderStatus.ACTIVE,
isLoadingMessages: false,
runtimeActive: false, // mock an inactive runtime setup
}));
const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage");
const user = userEvent.setup();
@@ -119,8 +113,7 @@ describe("Empty state", () => {
async () => {
useWsClientMock.mockImplementation(() => ({
send: sendMock,
status: WsClientProviderStatus.ACTIVE,
isLoadingMessages: false,
runtimeActive: false, // mock an inactive runtime setup
}));
const user = userEvent.setup();
const { rerender } = renderWithProviders(<ChatInterface />, {
@@ -137,8 +130,7 @@ describe("Empty state", () => {
useWsClientMock.mockImplementation(() => ({
send: sendMock,
status: WsClientProviderStatus.ACTIVE,
isLoadingMessages: false,
runtimeActive: true, // mock an active runtime setup
}));
rerender(<ChatInterface />);
@@ -289,68 +281,6 @@ describe.skip("ChatInterface", () => {
expect(within(error).getByText("Something went wrong")).toBeInTheDocument();
});
it("should render both GitHub buttons initially when ghToken is available", () => {
vi.mock("@remix-run/react", async (importActual) => ({
...(await importActual<typeof import("@remix-run/react")>()),
useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
}));
const messages: Message[] = [
{
sender: "assistant",
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
},
];
renderChatInterface(messages);
const pushButton = screen.getByRole("button", { name: "Push to Branch" });
const prButton = screen.getByRole("button", { name: "Push & Create PR" });
expect(pushButton).toBeInTheDocument();
expect(prButton).toBeInTheDocument();
expect(pushButton).toHaveTextContent("Push to Branch");
expect(prButton).toHaveTextContent("Push & Create PR");
});
it("should render only 'Push changes to PR' button after PR is created", async () => {
vi.mock("@remix-run/react", async (importActual) => ({
...(await importActual<typeof import("@remix-run/react")>()),
useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
}));
const messages: Message[] = [
{
sender: "assistant",
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
},
];
const { rerender } = renderChatInterface(messages);
const user = userEvent.setup();
// Click the "Push & Create PR" button
const prButton = screen.getByRole("button", { name: "Push & Create PR" });
await user.click(prButton);
// Re-render to trigger state update
rerender(<ChatInterface />);
// Verify only one button is shown
const pushToPrButton = screen.getByRole("button", {
name: "Push changes to PR",
});
expect(pushToPrButton).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Push to Branch" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Push & Create PR" }),
).not.toBeInTheDocument();
});
it("should render feedback actions if there are more than 3 messages", () => {
const messages: Message[] = [
{

View File

@@ -4,7 +4,7 @@ import { renderWithProviders } from "test-utils";
import { describe, it, expect, vi, Mock, afterEach } from "vitest";
import toast from "#/utils/toast";
import AgentState from "#/types/agent-state";
import { FileExplorer } from "#/routes/_oh.app._index/file-explorer/file-explorer";
import FileExplorer from "#/components/file-explorer/file-explorer";
import OpenHands from "#/api/open-hands";
const toastSpy = vi.spyOn(toast, "error");

View File

@@ -1,204 +0,0 @@
import { renderHook } from "@testing-library/react";
import { useCloseWarning } from "#/hooks/use-close-warning";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { rootReducer } from "#/store";
import { UserPrefsProvider } from "#/context/user-prefs-context";
import AgentState from "#/types/agent-state";
import { test, expect, vi, beforeEach, afterEach } from "vitest";
const mockSettings = {
CLOSE_WARNING: "while_working",
LLM_MODEL: "",
LLM_BASE_URL: "",
AGENT: "",
LANGUAGE: "en",
LLM_API_KEY: "",
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
};
const createStore = (agentState = AgentState.FINISHED) => configureStore({
reducer: rootReducer,
preloadedState: {
agent: {
curAgentState: agentState,
},
fileState: {
files: [],
selectedPath: null,
modifiedFiles: {},
},
initalQuery: {
selectedRepository: null,
},
browser: {
url: "",
isLoading: false,
error: null,
},
chat: {
messages: [],
isLoading: false,
error: null,
},
code: {
content: "",
isLoading: false,
error: null,
},
cmd: {
output: "",
isLoading: false,
error: null,
},
jupyter: {
cells: [],
isLoading: false,
error: null,
},
securityAnalyzer: {
isLoading: false,
error: null,
},
status: {
isLoading: false,
error: null,
},
},
});
const mockStore = createStore();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={mockStore}>
<UserPrefsProvider initialSettings={mockSettings}>
{children}
</UserPrefsProvider>
</Provider>
);
beforeEach(() => {
window.addEventListener = vi.fn();
window.removeEventListener = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should add and remove event listener", () => {
const { unmount } = renderHook(() => useCloseWarning(), { wrapper });
expect(window.addEventListener).toHaveBeenCalledWith(
"beforeunload",
expect.any(Function)
);
unmount();
expect(window.removeEventListener).toHaveBeenCalledWith(
"beforeunload",
expect.any(Function)
);
});
test("should prevent unload when agent is working and setting is while_working", () => {
const store = createStore(AgentState.RUNNING);
const customWrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>
<UserPrefsProvider initialSettings={mockSettings}>
{children}
</UserPrefsProvider>
</Provider>
);
renderHook(() => useCloseWarning(), { wrapper: customWrapper });
const addEventListenerMock = window.addEventListener as unknown as vi.Mock;
const handler = addEventListenerMock.mock.calls[0][1];
const mockEvent = {
preventDefault: vi.fn(),
returnValue: "",
};
handler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
test("should not prevent unload when agent is not working and setting is while_working", () => {
renderHook(() => useCloseWarning(), { wrapper });
const addEventListenerMock = window.addEventListener as unknown as vi.Mock;
const handler = addEventListenerMock.mock.calls[0][1];
const mockEvent = {
preventDefault: vi.fn(),
returnValue: "",
};
handler(mockEvent);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
test("should always prevent unload when setting is always", () => {
const customSettings = {
...mockSettings,
CLOSE_WARNING: "always",
};
const customWrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={mockStore}>
<UserPrefsProvider initialSettings={customSettings}>
{children}
</UserPrefsProvider>
</Provider>
);
renderHook(() => useCloseWarning(), { wrapper: customWrapper });
const addEventListenerMock = window.addEventListener as unknown as vi.Mock;
const handler = addEventListenerMock.mock.calls[0][1];
const mockEvent = {
preventDefault: vi.fn(),
returnValue: "",
};
handler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
test("should never prevent unload when setting is never", () => {
const customSettings = {
...mockSettings,
CLOSE_WARNING: "never",
};
const customWrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={mockStore}>
<UserPrefsProvider initialSettings={customSettings}>
{children}
</UserPrefsProvider>
</Provider>
);
renderHook(() => useCloseWarning(), { wrapper: customWrapper });
const addEventListenerMock = window.addEventListener as unknown as vi.Mock;
const handler = addEventListenerMock.mock.calls[0][1];
const mockEvent = {
preventDefault: vi.fn(),
returnValue: "",
};
handler(mockEvent);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});

View File

@@ -4,7 +4,7 @@ import { afterEach } from "node:test";
import { ReactNode } from "react";
import { useTerminal } from "#/hooks/use-terminal";
import { Command } from "#/state/command-slice";
import { WsClientProvider } from "#/context/ws-client-provider";
interface TestTerminalComponentProps {
commands: Command[];
@@ -25,8 +25,15 @@ interface WrapperProps {
function Wrapper({ children }: WrapperProps) {
return (
<div>{children}</div>
)
<WsClientProvider
enabled
token="NO_JWT"
ghToken="NO_GITHUB"
settings={null}
>
{children}
</WsClientProvider>
);
}
describe("useTerminal", () => {

View File

@@ -3,7 +3,7 @@ import { createRemixStub } from "@remix-run/testing";
import { screen, waitFor, within } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import userEvent from "@testing-library/user-event";
import MainApp from "#/routes/_oh/route";
import MainApp from "#/routes/_oh";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import i18n from "#/i18n";

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.14.3",
"version": "0.14.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.14.3",
"version": "0.14.2",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
@@ -41,7 +41,6 @@
"react-textarea-autosize": "^8.5.4",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.4",
"vite": "^5.4.9",
"web-vitals": "^3.5.2",
@@ -5532,11 +5531,6 @@
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
"dev": true
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
@@ -8551,46 +8545,6 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz",
"integrity": "sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -22722,32 +22676,6 @@
"tslib": "^2.0.3"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@@ -25460,14 +25388,6 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.14.3",
"version": "0.14.2",
"private": true,
"type": "module",
"engines": {
@@ -40,7 +40,6 @@
"react-textarea-autosize": "^8.5.4",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.4",
"vite": "^5.4.9",
"web-vitals": "^3.5.2",

View File

@@ -2,4 +2,4 @@
"APP_MODE": "oss",
"GITHUB_CLIENT_ID": "",
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
}
}

View File

@@ -0,0 +1,245 @@
import { useDispatch, useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatMessage } from "./chat-message";
import { FeedbackActions } from "./feedback-actions";
import { ImageCarousel } from "./image-carousel";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { addUserMessage } from "#/state/chat-slice";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "./feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import TypingIndicator from "./chat/typing-indicator";
import ConfirmationButtons from "./chat/confirmation-buttons";
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,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import OpenHands from "#/api/open-hands";
import { downloadWorkspace } from "#/utils/download-workspace";
import { SuggestionItem } from "./suggestion-item";
import { useAuth } from "#/context/auth-context";
import { CostDisplay } from "./cost-display";
const isErrorMessage = (
message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message;
export function ChatInterface() {
const { gitHubToken } = useAuth();
const { send, status, isLoadingMessages } = useWsClient();
const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);
const { messages } = useSelector((state: RootState) => state.chat);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const [isDownloading, setIsDownloading] = React.useState(false);
React.useEffect(() => {
if (status === WsClientProviderStatus.ACTIVE) {
try {
OpenHands.getRuntimeId().then(({ runtime_id }) => {
// eslint-disable-next-line no-console
console.log(
"Runtime ID: %c%s",
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
runtime_id,
);
});
} catch (e) {
console.warn("Runtime ID not available in this environment");
}
}
}, [status]);
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));
};
const handleSendContinueMsg = () => {
handleSendMessage("Continue", []);
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
setFeedbackModalIsOpen(true);
setFeedbackPolarity(polarity);
};
const handleDownloadWorkspace = async () => {
setIsDownloading(true);
try {
await downloadWorkspace();
} catch (error) {
// TODO: Handle error
} finally {
setIsDownloading(false);
}
};
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&apos;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)}
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{isLoadingMessages && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-t-[4px] border-primary-500 rounded-full animate-spin" />
</div>
)}
{!isLoadingMessages &&
messages.map((message, index) =>
isErrorMessage(message) ? (
<ErrorMessage
key={index}
id={message.id}
message={message.message}
/>
) : (
<ChatMessage
key={index}
type={message.sender}
message={message.content}
>
{message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{messages.length - 1 === index &&
message.sender === "assistant" &&
curAgentState === AgentState.AWAITING_USER_CONFIRMATION && (
<ConfirmationButtons />
)}
</ChatMessage>
),
)}
{(curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.FINISHED) && (
<div className="flex flex-col gap-2 mb-2">
{gitHubToken ? (
<SuggestionItem
suggestion={{
label: "Push to GitHub",
value:
"Please push the changes to GitHub and open a pull request.",
}}
onClick={(value) => {
handleSendMessage(value, []);
}}
/>
) : (
<SuggestionItem
suggestion={{
label: !isDownloading
? "Download .zip"
: "Downloading, please wait...",
value: "Download .zip",
}}
onClick={handleDownloadWorkspace}
/>
)}
</div>
)}
</div>
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<FeedbackActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
/>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{messages.length > 2 &&
curAgentState === AgentState.AWAITING_USER_INPUT && (
<ContinueButton onClick={handleSendContinueMsg} />
)}
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
</div>
{!hitBottom && <ScrollToBottomButton onClick={scrollDomToBottom} />}
</div>
<InteractiveChatBox
onSubmit={handleSendMessage}
onStop={handleStop}
isDisabled={
curAgentState === AgentState.LOADING ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
value={messageToSend ?? undefined}
onChange={setMessageToSend}
/>
</div>
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
polarity={feedbackPolarity}
/>
<CostDisplay />
</div>
);
}

View File

@@ -0,0 +1,31 @@
import React from "react";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
export function CostDisplay() {
const { totalCost, lastStepCosts } = useSelector((state: RootState) => state.cost);
return (
<div className="fixed bottom-24 right-4 bg-neutral-700 border border-neutral-600 rounded-lg p-3 text-sm">
<div className="mb-2">
<span className="text-neutral-400">Total Cost:</span>{" "}
<span className="font-semibold">${totalCost.toFixed(4)}</span>
</div>
{lastStepCosts.length > 0 && (
<div>
<span className="text-neutral-400">Last Steps:</span>
<div className="mt-1 space-y-1">
{lastStepCosts.map((step, i) => (
<div key={i} className="flex justify-between">
<span className="text-neutral-300 truncate mr-4" title={step.description}>
{step.description}
</span>
<span className="text-neutral-300">${step.cost.toFixed(4)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
import React from "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/chat-slice";
import {
getCloneRepoCommand,
getGitHubTokenCommand,
} from "#/services/terminal-service";
import {
clearFiles,
clearInitialQuery,
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
import store, { RootState } from "#/store";
import { createChatMessage } from "#/services/chat-service";
import { isGitHubErrorReponse } from "#/api/github";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { setCurrentAgentState } from "#/state/agent-slice";
import AgentState from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useAuth } from "#/context/auth-context";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserPrefs } from "#/context/user-prefs-context";
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 { setToken, gitHubToken } = useAuth();
const { settings } = useUserPrefs();
const { events, status, send } = useWsClient();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
const dispatch = useDispatch();
const { files, importedProjectZip, initialQuery } = useSelector(
(state: RootState) => state.initalQuery,
);
const endSession = useEndSession();
// FIXME: Bad practice - should be handled with state
const { selectedRepository } = useSelector(
(state: RootState) => state.initalQuery,
);
const { data: user } = useGitHubUser();
const { mutate: uploadFiles } = useUploadFiles();
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const userId = React.useMemo(() => {
if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
}, [user]);
React.useEffect(() => {
if (!events.length) {
return;
}
const event = events[events.length - 1];
if (event.token && typeof event.token === "string") {
setToken(event.token);
return;
}
if (isServerError(event)) {
if (event.error_code === 401) {
toast.error("Session expired.");
endSession();
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;
if (status === WsClientProviderStatus.ACTIVE) {
let additionalInfo = "";
if (gitHubToken && selectedRepository) {
send(getCloneRepoCommand(gitHubToken, selectedRepository));
additionalInfo = `Repository ${selectedRepository} 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
dispatch(clearInitialQuery()); // reset initial query
}
}
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 && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(gitHubToken));
}
}, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
if (runtimeActive && importedProjectZip) {
const blob = base64ToBlob(importedProjectZip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
toast.error("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
}
}, [runtimeActive, importedProjectZip]);
React.useEffect(() => {
if (settings.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [settings.LLM_API_KEY]);
return children;
}

View File

@@ -0,0 +1,307 @@
import React from "react";
import {
IoIosArrowBack,
IoIosArrowForward,
IoIosRefresh,
IoIosCloudUpload,
} from "react-icons/io";
import { useDispatch, useSelector } from "react-redux";
import { IoFileTray } from "react-icons/io5";
import { useTranslation } from "react-i18next";
import { twMerge } from "tailwind-merge";
import AgentState from "#/types/agent-state";
import { addAssistantMessage } from "#/state/chat-slice";
import IconButton from "../icon-button";
import ExplorerTree from "./explorer-tree";
import toast from "#/utils/toast";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import OpenHands from "#/api/open-hands";
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
import { useListFiles } from "#/hooks/query/use-list-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
interface ExplorerActionsProps {
onRefresh: () => void;
onUpload: () => void;
toggleHidden: () => void;
isHidden: boolean;
}
function ExplorerActions({
toggleHidden,
onRefresh,
onUpload,
isHidden,
}: ExplorerActionsProps) {
return (
<div
className={twMerge(
"transform flex h-[24px] items-center gap-1",
isHidden ? "right-3" : "right-2",
)}
>
{!isHidden && (
<>
<IconButton
icon={
<IoIosRefresh
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="refresh"
ariaLabel="Refresh workspace"
onClick={onRefresh}
/>
<IconButton
icon={
<IoIosCloudUpload
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="upload"
ariaLabel="Upload File"
onClick={onUpload}
/>
</>
)}
<IconButton
icon={
isHidden ? (
<IoIosArrowForward
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
) : (
<IoIosArrowBack
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
)
}
testId="toggle"
ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
onClick={toggleHidden}
/>
</div>
);
}
interface FileExplorerProps {
isOpen: boolean;
onToggle: () => void;
}
function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
const [isDragging, setIsDragging] = React.useState(false);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const dispatch = useDispatch();
const { t } = useTranslation();
const selectFileInput = () => {
fileInputRef.current?.click(); // Trigger the file browser
};
const { data: paths, refetch, error } = useListFiles();
const handleUploadSuccess = (data: FileUploadSuccessResponse) => {
const uploadedCount = data.uploaded_files.length;
const skippedCount = data.skipped_files.length;
if (uploadedCount > 0) {
toast.success(
`upload-success-${new Date().getTime()}`,
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
count: uploadedCount,
}),
);
}
if (skippedCount > 0) {
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
count: skippedCount,
});
toast.info(message);
}
if (uploadedCount === 0 && skippedCount === 0) {
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
}
};
const handleUploadError = (e: Error) => {
toast.error(
`upload-error-${new Date().getTime()}`,
e.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
);
};
const { mutate: uploadFiles } = useUploadFiles();
const refreshWorkspace = () => {
if (
curAgentState !== AgentState.LOADING &&
curAgentState !== AgentState.STOPPED
) {
refetch();
}
};
const uploadFileData = (files: FileList) => {
uploadFiles(
{ files: Array.from(files) },
{ onSuccess: handleUploadSuccess, onError: handleUploadError },
);
refreshWorkspace();
};
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]);
return (
<div
data-testid="file-explorer"
className="relative h-full"
onDragEnter={() => {
setIsDragging(true);
}}
onDragEnd={() => {
setIsDragging(false);
}}
>
{isDragging && (
<div
data-testid="dropzone"
onDragLeave={() => setIsDragging(false)}
onDrop={(event) => {
event.preventDefault();
const { files: droppedFiles } = event.dataTransfer;
if (droppedFiles.length > 0) {
uploadFileData(droppedFiles);
}
setIsDragging(false);
}}
onDragOver={(event) => event.preventDefault()}
className="z-10 absolute flex flex-col justify-center items-center bg-black top-0 bottom-0 left-0 right-0 opacity-65"
>
<IoFileTray size={32} />
<p className="font-bold text-xl">
{t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
</p>
</div>
)}
<div
className={twMerge(
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
!isOpen ? "w-12" : "w-60",
)}
>
<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(
"flex items-center",
!isOpen ? "justify-center" : "justify-between",
)}
>
{isOpen && (
<div className="text-neutral-300 font-bold text-sm">
{t(I18nKey.EXPLORER$LABEL_WORKSPACE)}
</div>
)}
<ExplorerActions
isHidden={!isOpen}
toggleHidden={onToggle}
onRefresh={refreshWorkspace}
onUpload={selectFileInput}
/>
</div>
</div>
{!error && (
<div className="overflow-auto flex-grow min-h-0">
<div style={{ display: !isOpen ? "none" : "block" }}>
<ExplorerTree files={paths || []} />
</div>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-neutral-300 text-sm">{error.message}</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"
type="file"
multiple
ref={fileInputRef}
style={{ display: "none" }}
onChange={(event) => {
const { files: selectedFiles } = event.target;
if (selectedFiles && selectedFiles.length > 0) {
uploadFileData(selectedFiles);
}
}}
/>
</div>
</div>
);
}
export default FileExplorer;

View File

@@ -351,39 +351,6 @@ export function SettingsForm({
>
{t(I18nKey.SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL)}
</Switch>
<fieldset className="flex flex-col gap-2">
<label
htmlFor="close-warning"
className="font-[500] text-[#A3A3A3] text-xs"
>
{t("CONFIGURATION$CLOSE_WARNING_LABEL")}
</label>
<Autocomplete
isDisabled={disabled}
isRequired
id="close-warning"
name="close-warning"
aria-label="Close Warning"
defaultSelectedKey={settings.CLOSE_WARNING}
inputProps={{
classNames: {
inputWrapper:
"bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
},
}}
>
<AutocompleteItem key="always" value="always">
Always
</AutocompleteItem>
<AutocompleteItem key="while_working" value="while_working">
While agent is working
</AutocompleteItem>
<AutocompleteItem key="never" value="never">
Never
</AutocompleteItem>
</Autocomplete>
</fieldset>
</>
)}
</div>

View File

@@ -14,19 +14,19 @@ import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
interface AccountSettingsFormProps {
interface AccountSettingsModalProps {
onClose: () => void;
selectedLanguage: string;
gitHubError: boolean;
analyticsConsent: string | null;
}
export function AccountSettingsForm({
function AccountSettingsModal({
onClose,
selectedLanguage,
gitHubError,
analyticsConsent,
}: AccountSettingsFormProps) {
}: AccountSettingsModalProps) {
const { gitHubToken, setGitHubToken, logout } = useAuth();
const { saveSettings } = useUserPrefs();
const { t } = useTranslation();
@@ -136,3 +136,5 @@ export function AccountSettingsForm({
</ModalBody>
);
}
export default AccountSettingsModal;

View File

@@ -1,53 +0,0 @@
import { useTranslation } from "react-i18next";
import { Dialog } from "@headlessui/react";
interface CloseWarningDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function CloseWarningDialog({
isOpen,
onClose,
onConfirm,
}: CloseWarningDialogProps) {
const { t } = useTranslation();
return (
<Dialog
open={isOpen}
onClose={onClose}
className="relative z-50"
>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-sm rounded bg-white p-6">
<Dialog.Title className="text-lg font-medium leading-6 text-gray-900">
{t("CLOSE_WARNING$DIALOG_TITLE")}
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
{t("CLOSE_WARNING$DIALOG_MESSAGE")}
</Dialog.Description>
<div className="mt-4 flex justify-end space-x-2">
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
onClick={onClose}
>
{t("CLOSE_WARNING$DIALOG_CANCEL")}
</button>
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
onClick={onConfirm}
>
{t("CLOSE_WARNING$DIALOG_CONFIRM")}
</button>
</div>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@@ -7,7 +7,7 @@ interface SuggestionItemProps {
export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
return (
<li className="list-none border border-neutral-600 rounded-xl hover:bg-neutral-700 flex-1">
<li className="list-none border border-neutral-600 rounded-xl hover:bg-neutral-700">
<button
type="button"
data-testid="suggestion"

View File

@@ -1,5 +1,4 @@
import React from "react";
import posthog from "posthog-js";
import {
getSettings,
Settings,
@@ -29,12 +28,6 @@ function UserPrefsProvider({ children }: React.PropsWithChildren) {
setSettingsAreUpToDate(checkIfSettingsAreUpToDate());
};
React.useEffect(() => {
if (settings.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [settings.LLM_API_KEY]);
const value = React.useMemo(
() => ({
settings,

View File

@@ -1,6 +1,5 @@
import posthog from "posthog-js";
import React from "react";
import { io, Socket } from "socket.io-client";
import { Settings } from "#/services/settings";
import ActionType from "#/types/action-type";
import EventLogger from "#/utils/event-logger";
@@ -11,6 +10,8 @@ import { useRate } from "#/utils/use-rate";
const isOpenHandsMessage = (event: Record<string, unknown>) =>
event.action === "message";
const RECONNECT_RETRIES = 5;
export enum WsClientProviderStatus {
STOPPED,
OPENING,
@@ -48,54 +49,41 @@ export function WsClientProvider({
settings,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const sioRef = React.useRef<Socket | null>(null);
const wsRef = React.useRef<WebSocket | null>(null);
const tokenRef = React.useRef<string | null>(token);
const ghTokenRef = React.useRef<string | null>(ghToken);
const disconnectRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
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 lastEventRef = React.useRef<Record<string, unknown> | null>(null);
const [retryCount, setRetryCount] = React.useState(RECONNECT_RETRIES);
const messageRateHandler = useRate({ threshold: 500 });
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
if (!wsRef.current) {
EventLogger.error("WebSocket is not connected.");
return;
}
sioRef.current.emit("oh_action", event);
wsRef.current.send(JSON.stringify(event));
}
function handleConnect() {
function handleOpen() {
setRetryCount(RECONNECT_RETRIES);
setStatus(WsClientProviderStatus.OPENING);
const initEvent: Record<string, unknown> = {
const initEvent = {
action: ActionType.INIT,
args: settings,
};
if (token) {
initEvent.token = token;
}
if (ghToken) {
initEvent.github_token = ghToken;
}
const lastEvent = lastEventRef.current;
if (lastEvent && !Number.isNaN(parseInt(lastEvent.id as string, 10))) {
initEvent.latest_event_id = lastEvent.id;
}
send(initEvent);
}
function handleMessage(event: Record<string, unknown>) {
function handleMessage(messageEvent: MessageEvent) {
const event = JSON.parse(messageEvent.data);
if (isOpenHandsMessage(event)) {
messageRateHandler.record(new Date().getTime());
}
setEvents((prevEvents) => [...prevEvents, event]);
lastEventRef.current = event;
const extras = event.extras as Record<string, unknown>;
if (extras?.agent_state === AgentState.INIT) {
if (event.extras?.agent_state === AgentState.INIT) {
setStatus(WsClientProviderStatus.ACTIVE);
}
if (
@@ -103,83 +91,95 @@ export function WsClientProvider({
event?.observation === "error"
) {
setStatus(WsClientProviderStatus.ERROR);
return;
}
if (!event.token) {
handleAssistantMessage(event);
handleAssistantMessage(event);
}
function handleClose() {
if (retryCount) {
setTimeout(() => {
setRetryCount(retryCount - 1);
}, 1000);
} else {
setStatus(WsClientProviderStatus.STOPPED);
setEvents([]);
}
wsRef.current = null;
}
function handleDisconnect() {
setStatus(WsClientProviderStatus.STOPPED);
}
function handleError() {
function handleError(event: Event) {
posthog.capture("socket_error");
EventLogger.event(event, "SOCKET ERROR");
setStatus(WsClientProviderStatus.ERROR);
}
// Connect websocket
React.useEffect(() => {
let sio = sioRef.current;
let ws = wsRef.current;
// If disabled disconnect any existing websockets...
if (!enabled) {
if (sio) {
sio.disconnect();
// 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 disconnected,
// If there is no websocket or the tokens have changed or the current websocket is closed,
// create a new one
if (
!sio ||
(tokenRef.current && token && token !== tokenRef.current) ||
ghToken !== ghTokenRef.current
!ws ||
(tokenRef.current && token !== tokenRef.current) ||
ghToken !== ghTokenRef.current ||
ws.readyState === WebSocket.CLOSED ||
ws.readyState === WebSocket.CLOSING
) {
sio?.disconnect();
ws?.close();
const baseUrl =
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
sio = io(baseUrl, {
transports: ["websocket"],
});
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",
]);
}
sio.on("connect", handleConnect);
sio.on("oh_event", handleMessage);
sio.on("connect_error", handleError);
sio.on("connect_failed", handleError);
sio.on("disconnect", handleDisconnect);
sioRef.current = sio;
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 () => {
sio.off("connect", handleConnect);
sio.off("oh_event", handleMessage);
sio.off("connect_error", handleError);
sio.off("connect_failed", handleError);
sio.off("disconnect", handleDisconnect);
ws.removeEventListener("open", handleOpen);
ws.removeEventListener("message", handleMessage);
ws.removeEventListener("error", handleError);
ws.removeEventListener("close", handleClose);
};
}, [enabled, token, ghToken]);
}, [enabled, token, ghToken, retryCount]);
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
// before actually disconnecting the socket and cancel the operation if the component gets remounted.
// before actually closing the socket and cancel the operation if the component gets remounted.
React.useEffect(() => {
const timeout = disconnectRef.current;
const timeout = closeRef.current;
if (timeout != null) {
clearTimeout(timeout);
}
return () => {
disconnectRef.current = setTimeout(() => {
const sio = sioRef.current;
if (sio) {
sio.off("disconnect", handleDisconnect);
sio.disconnect();
closeRef.current = setTimeout(() => {
const ws = wsRef.current;
if (ws) {
ws.removeEventListener("close", handleClose);
ws.close();
}
}, 100);
};

View File

@@ -1,32 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import OpenHands from "#/api/open-hands";
export const useConversationConfig = () => {
const { status } = useWsClient();
const query = useQuery({
queryKey: ["conversation_config"],
queryFn: OpenHands.getRuntimeId,
enabled: status === WsClientProviderStatus.ACTIVE,
});
React.useEffect(() => {
if (query.data) {
const { runtime_id: runtimeId } = query.data;
// eslint-disable-next-line no-console
console.log(
"Runtime ID: %c%s",
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
runtimeId,
);
}
}, [query.data]);
return query;
};

View File

@@ -1,43 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import toast from "#/utils/toast";
import { addAssistantMessage } from "#/state/chat-slice";
import { I18nKey } from "#/i18n/declaration";
import OpenHands from "#/api/open-hands";
export const useVSCodeUrl = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const data = useQuery({
queryKey: ["vscode_url"],
queryFn: OpenHands.getVSCodeUrl,
enabled: false,
});
const { data: vscodeUrlObject, isFetching } = data;
React.useEffect(() => {
if (isFetching) return;
if (vscodeUrlObject?.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(vscodeUrlObject.vscode_url, "_blank");
} else if (vscodeUrlObject?.error) {
toast.error(
`open-vscode-error-${new Date().getTime()}`,
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
error: vscodeUrlObject.error,
}),
);
}
}, [vscodeUrlObject, isFetching]);
return data;
};

View File

@@ -1,32 +0,0 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '#/store';
import AgentState from '#/types/agent-state';
import { useUserPrefs } from '#/context/user-prefs-context';
export function useCloseWarning() {
const [showWarning, setShowWarning] = useState(false);
const { settings } = useUserPrefs();
const agentState = useSelector((state: RootState) => state.agent.curAgentState);
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
const isWorking = agentState === AgentState.RUNNING || agentState === AgentState.STARTING;
if (settings.CLOSE_WARNING === 'always' ||
(settings.CLOSE_WARNING === 'while_working' && isWorking)) {
e.preventDefault();
e.returnValue = '';
return '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [agentState, settings.CLOSE_WARNING]);
return {
showWarning,
setShowWarning,
};
}

View File

@@ -1,86 +0,0 @@
{
"CONFIGURATION$CLOSE_WARNING_LABEL": {
"en": "Close warning",
"zh-CN": "关闭警告",
"de": "Schließen-Warnung",
"ko-KR": "닫기 경고",
"no": "Lukkevarsel",
"zh-TW": "關閉警告",
"it": "Avviso di chiusura",
"pt": "Aviso de fechamento",
"es": "Aviso de cierre",
"ar": "تحذير الإغلاق",
"fr": "Avertissement de fermeture",
"tr": "Kapatma uyarısı"
},
"CONFIGURATION$CLOSE_WARNING_PLACEHOLDER": {
"en": "When to show close warning",
"zh-CN": "何时显示关闭警告",
"de": "Wann die Schließen-Warnung anzeigen",
"ko-KR": "닫기 경고를 표시할 시기",
"no": "Når skal lukkevarsel vises",
"zh-TW": "何時顯示關閉警告",
"it": "Quando mostrare l'avviso di chiusura",
"pt": "Quando mostrar aviso de fechamento",
"es": "Cuándo mostrar aviso de cierre",
"ar": "متى يظهر تحذير الإغلاق",
"fr": "Quand afficher l'avertissement de fermeture",
"tr": "Kapatma uyarısı ne zaman gösterilsin"
},
"CLOSE_WARNING$DIALOG_TITLE": {
"en": "Close OpenHands?",
"zh-CN": "关闭 OpenHands",
"de": "OpenHands schließen?",
"ko-KR": "OpenHands를 닫으시겠습니까?",
"no": "Lukk OpenHands?",
"zh-TW": "關閉 OpenHands",
"it": "Chiudere OpenHands?",
"pt": "Fechar OpenHands?",
"es": "¿Cerrar OpenHands?",
"ar": "إغلاق OpenHands؟",
"fr": "Fermer OpenHands ?",
"tr": "OpenHands kapatılsın mı?"
},
"CLOSE_WARNING$DIALOG_MESSAGE": {
"en": "The agent is currently working. Closing the window will terminate its work and any progress will be lost.",
"zh-CN": "智能体正在工作中。关闭窗口将终止其工作,所有进度都将丢失。",
"de": "Der Agent arbeitet gerade. Wenn Sie das Fenster schließen, wird seine Arbeit beendet und der Fortschritt geht verloren.",
"ko-KR": "에이전트가 현재 작업 중입니다. 창을 닫으면 작업이 종료되고 모든 진행 상황이 손실됩니다.",
"no": "Agenten jobber for øyeblikket. Å lukke vinduet vil avslutte arbeidet og all fremgang vil gå tapt.",
"zh-TW": "智能體正在工作中。關閉視窗將終止其工作,所有進度都將丟失。",
"it": "L'agente sta attualmente lavorando. La chiusura della finestra terminerà il suo lavoro e qualsiasi progresso andrà perso.",
"pt": "O agente está trabalhando no momento. Fechar a janela encerrará seu trabalho e todo o progresso será perdido.",
"es": "El agente está trabajando actualmente. Cerrar la ventana terminará su trabajo y se perderá todo el progreso.",
"ar": "الوكيل يعمل حاليا. سيؤدي إغلاق النافذة إلى إنهاء عمله وسيتم فقد أي تقدم.",
"fr": "L'agent est en train de travailler. Fermer la fenêtre mettra fin à son travail et tout progrès sera perdu.",
"tr": "Ajan şu anda çalışıyor. Pencereyi kapatmak işini sonlandıracak ve tüm ilerleme kaybedilecek."
},
"CLOSE_WARNING$DIALOG_CONFIRM": {
"en": "Close anyway",
"zh-CN": "仍然关闭",
"de": "Trotzdem schließen",
"ko-KR": "그래도 닫기",
"no": "Lukk likevel",
"zh-TW": "仍然關閉",
"it": "Chiudi comunque",
"pt": "Fechar mesmo assim",
"es": "Cerrar de todos modos",
"ar": "إغلاق على أي حال",
"fr": "Fermer quand même",
"tr": "Yine de kapat"
},
"CLOSE_WARNING$DIALOG_CANCEL": {
"en": "Cancel",
"zh-CN": "取消",
"de": "Abbrechen",
"ko-KR": "취소",
"no": "Avbryt",
"zh-TW": "取消",
"it": "Annulla",
"pt": "Cancelar",
"es": "Cancelar",
"ar": "إلغاء",
"fr": "Annuler",
"tr": "İptal"
}
}

View File

@@ -2013,91 +2013,4 @@
"en": "Download as .zip",
"es": "Descargar como .zip"
}
,
"CONFIGURATION$CLOSE_WARNING_LABEL": {
"en": "Close warning",
"zh-CN": "关闭警告",
"de": "Schließen-Warnung",
"ko-KR": "닫기 경고",
"no": "Lukkevarsel",
"zh-TW": "關閉警告",
"it": "Avviso di chiusura",
"pt": "Aviso de fechamento",
"es": "Aviso de cierre",
"ar": "تحذير الإغلاق",
"fr": "Avertissement de fermeture",
"tr": "Kapatma uyarısı"
},
"CONFIGURATION$CLOSE_WARNING_PLACEHOLDER": {
"en": "When to show close warning",
"zh-CN": "何时显示关闭警告",
"de": "Wann die Schließen-Warnung anzeigen",
"ko-KR": "닫기 경고를 표시할 시기",
"no": "Når skal lukkevarsel vises",
"zh-TW": "何時顯示關閉警告",
"it": "Quando mostrare l'avviso di chiusura",
"pt": "Quando mostrar aviso de fechamento",
"es": "Cuándo mostrar aviso de cierre",
"ar": "متى يظهر تحذير الإغلاق",
"fr": "Quand afficher l'avertissement de fermeture",
"tr": "Kapatma uyarısı ne zaman gösterilsin"
},
"CLOSE_WARNING$DIALOG_TITLE": {
"en": "Close OpenHands?",
"zh-CN": "关闭 OpenHands",
"de": "OpenHands schließen?",
"ko-KR": "OpenHands를 닫으시겠습니까?",
"no": "Lukk OpenHands?",
"zh-TW": "關閉 OpenHands",
"it": "Chiudere OpenHands?",
"pt": "Fechar OpenHands?",
"es": "¿Cerrar OpenHands?",
"ar": "إغلاق OpenHands؟",
"fr": "Fermer OpenHands ?",
"tr": "OpenHands kapatılsın mı?"
},
"CLOSE_WARNING$DIALOG_MESSAGE": {
"en": "The agent is currently working. Closing the window will terminate its work and any progress will be lost.",
"zh-CN": "智能体正在工作中。关闭窗口将终止其工作,所有进度都将丢失。",
"de": "Der Agent arbeitet gerade. Wenn Sie das Fenster schließen, wird seine Arbeit beendet und der Fortschritt geht verloren.",
"ko-KR": "에이전트가 현재 작업 중입니다. 창을 닫으면 작업이 종료되고 모든 진행 상황이 손실됩니다.",
"no": "Agenten jobber for øyeblikket. Å lukke vinduet vil avslutte arbeidet og all fremgang vil gå tapt.",
"zh-TW": "智能體正在工作中。關閉視窗將終止其工作,所有進度都將丟失。",
"it": "L'agente sta attualmente lavorando. La chiusura della finestra terminerà il suo lavoro e qualsiasi progresso andrà perso.",
"pt": "O agente está trabalhando no momento. Fechar a janela encerrará seu trabalho e todo o progresso será perdido.",
"es": "El agente está trabajando actualmente. Cerrar la ventana terminará su trabajo y se perderá todo el progreso.",
"ar": "الوكيل يعمل حاليا. سيؤدي إغلاق النافذة إلى إنهاء عمله وسيتم فقد أي تقدم.",
"fr": "L'agent est en train de travailler. Fermer la fenêtre mettra fin à son travail et tout progrès sera perdu.",
"tr": "Ajan şu anda çalışıyor. Pencereyi kapatmak işini sonlandıracak ve tüm ilerleme kaybedilecek."
},
"CLOSE_WARNING$DIALOG_CONFIRM": {
"en": "Close anyway",
"zh-CN": "仍然关闭",
"de": "Trotzdem schließen",
"ko-KR": "그래도 닫기",
"no": "Lukk likevel",
"zh-TW": "仍然關閉",
"it": "Chiudi comunque",
"pt": "Fechar mesmo assim",
"es": "Cerrar de todos modos",
"ar": "إغلاق على أي حال",
"fr": "Fermer quand même",
"tr": "Yine de kapat"
},
"CLOSE_WARNING$DIALOG_CANCEL": {
"en": "Cancel",
"zh-CN": "取消",
"de": "Abbrechen",
"ko-KR": "취소",
"no": "Avbryt",
"zh-TW": "取消",
"it": "Annulla",
"pt": "Cancelar",
"es": "Cancelar",
"ar": "إلغاء",
"fr": "Annuler",
"tr": "İptal"
}
}

View File

@@ -49,13 +49,13 @@ const generateAgentRunObservation = (): CommandObservation => ({
},
});
const api = ws.link("ws://localhost:3000/socket.io/?EIO=4&transport=websocket");
const api = ws.link("ws://localhost:3000/ws");
export const handlers: WebSocketHandler[] = [
api.addEventListener("connection", ({ client }) => {
client.send(
JSON.stringify({
status: 200,
status: "ok",
token: Math.random().toString(36).substring(7),
} satisfies TokenConfigSuccess),
);

View File

@@ -1,30 +0,0 @@
import { SuggestionBox } from "./suggestion-box";
interface ImportProjectSuggestionBoxProps {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
export function ImportProjectSuggestionBox({
onChange,
}: ImportProjectSuggestionBoxProps) {
return (
<SuggestionBox
title="+ Import Project"
content={
<label htmlFor="import-project" className="w-full flex justify-center">
<span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
Upload a .zip
</span>
<input
hidden
type="file"
accept="application/zip"
id="import-project"
multiple={false}
onChange={onChange}
/>
</label>
}
/>
);
}

View File

@@ -1,6 +1,7 @@
import { useLocation, useNavigate } from "@remix-run/react";
import React from "react";
import { useDispatch } from "react-redux";
import { SuggestionBox } from "./suggestion-box";
import { TaskForm } from "./task-form";
import { HeroHeading } from "./hero-heading";
import { setImportedProjectZip } from "#/state/initial-query-slice";
@@ -11,7 +12,6 @@ import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useConfig } from "#/hooks/query/use-config";
import { useAuth } from "#/context/auth-context";
import { ImportProjectSuggestionBox } from "./import-project-suggestion-box";
function Home() {
const { token, gitHubToken } = useAuth();
@@ -46,7 +46,6 @@ function Home() {
<div className="flex flex-col gap-2 w-full">
<TaskForm ref={formRef} />
</div>
<div className="flex gap-4 w-full">
<GitHubRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
@@ -55,17 +54,38 @@ function Home() {
}
gitHubAuthUrl={gitHubAuthUrl}
user={user || null}
// onEndReached={}
/>
<ImportProjectSuggestionBox
onChange={async (event) => {
if (event.target.files) {
const zip = event.target.files[0];
dispatch(setImportedProjectZip(await convertZipToBase64(zip)));
formRef.current?.requestSubmit();
} else {
// TODO: handle error
}
}}
<SuggestionBox
title="+ Import Project"
content={
<label
htmlFor="import-project"
className="w-full flex justify-center"
>
<span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
Upload a .zip
</span>
<input
hidden
type="file"
accept="application/zip"
id="import-project"
multiple={false}
onChange={async (event) => {
if (event.target.files) {
const zip = event.target.files[0];
dispatch(
setImportedProjectZip(await convertZipToBase64(zip)),
);
formRef.current?.requestSubmit();
} else {
// TODO: handle error
}
}}
/>
</label>
}
/>
</div>
</div>

View File

@@ -1,11 +0,0 @@
export const ASSET_FILE_TYPES = [
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".pdf",
".mp4",
".webm",
".ogg",
];

View File

@@ -1,30 +0,0 @@
import { cn } from "#/utils/utils";
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
interface OpenVSCodeButtonProps {
isDisabled: boolean;
onClick: () => void;
}
export function OpenVSCodeButton({
isDisabled,
onClick,
}: OpenVSCodeButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
className={cn(
"mt-auto mb-2 w-full h-10 text-white rounded flex items-center justify-center gap-2 transition-colors",
isDisabled
? "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>
);
}

View File

@@ -1,22 +0,0 @@
import { IoIosRefresh } from "react-icons/io";
import IconButton from "#/components/icon-button";
interface RefreshIconButtonProps {
onClick: () => void;
}
export function RefreshIconButton({ onClick }: RefreshIconButtonProps) {
return (
<IconButton
icon={
<IoIosRefresh
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="refresh"
ariaLabel="Refresh workspace"
onClick={onClick}
/>
);
}

View File

@@ -1,33 +0,0 @@
import { IoIosArrowForward, IoIosArrowBack } from "react-icons/io";
import IconButton from "#/components/icon-button";
interface ToggleWorkspaceIconButtonProps {
onClick: () => void;
isHidden: boolean;
}
export function ToggleWorkspaceIconButton({
onClick,
isHidden,
}: ToggleWorkspaceIconButtonProps) {
return (
<IconButton
icon={
isHidden ? (
<IoIosArrowForward
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
) : (
<IoIosArrowBack
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
)
}
testId="toggle"
ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
onClick={onClick}
/>
);
}

View File

@@ -1,22 +0,0 @@
import { IoIosCloudUpload } from "react-icons/io";
import IconButton from "#/components/icon-button";
interface UploadIconButtonProps {
onClick: () => void;
}
export function UploadIconButton({ onClick }: UploadIconButtonProps) {
return (
<IconButton
icon={
<IoIosCloudUpload
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="upload"
ariaLabel="Upload File"
onClick={onClick}
/>
);
}

View File

@@ -1,27 +0,0 @@
import { useTranslation } from "react-i18next";
import { IoFileTray } from "react-icons/io5";
import { I18nKey } from "#/i18n/declaration";
interface DropzoneProps {
onDragLeave: () => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
}
export function Dropzone({ onDragLeave, onDrop }: DropzoneProps) {
const { t } = useTranslation();
return (
<div
data-testid="dropzone"
onDragLeave={onDragLeave}
onDrop={onDrop}
onDragOver={(event) => event.preventDefault()}
className="z-10 absolute flex flex-col justify-center items-center bg-black top-0 bottom-0 left-0 right-0 opacity-65"
>
<IoFileTray size={32} />
<p className="font-bold text-xl">
{t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
</p>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { cn } from "#/utils/utils";
import { RefreshIconButton } from "./buttons/refresh-icon-button";
import { ToggleWorkspaceIconButton } from "./buttons/toggle-workspace-icon-button";
import { UploadIconButton } from "./buttons/upload-icon-button";
interface ExplorerActionsProps {
onRefresh: () => void;
onUpload: () => void;
toggleHidden: () => void;
isHidden: boolean;
}
export function ExplorerActions({
toggleHidden,
onRefresh,
onUpload,
isHidden,
}: ExplorerActionsProps) {
return (
<div
className={cn(
"flex h-[24px] items-center gap-1",
isHidden ? "right-3" : "right-2",
)}
>
{!isHidden && (
<>
<RefreshIconButton onClick={onRefresh} />
<UploadIconButton onClick={onUpload} />
</>
)}
<ToggleWorkspaceIconButton isHidden={isHidden} onClick={toggleHidden} />
</div>
);
}

View File

@@ -1,42 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { ExplorerActions } from "./file-explorer-actions";
interface FileExplorerHeaderProps {
isOpen: boolean;
onToggle: () => void;
onRefreshWorkspace: () => void;
onUploadFile: () => void;
}
export function FileExplorerHeader({
isOpen,
onToggle,
onRefreshWorkspace,
onUploadFile,
}: FileExplorerHeaderProps) {
const { t } = useTranslation();
return (
<div
className={cn(
"sticky top-0 bg-neutral-800",
"flex items-center",
!isOpen ? "justify-center" : "justify-between",
)}
>
{isOpen && (
<div className="text-neutral-300 font-bold text-sm">
{t(I18nKey.EXPLORER$LABEL_WORKSPACE)}
</div>
)}
<ExplorerActions
isHidden={!isOpen}
toggleHidden={onToggle}
onRefresh={onRefreshWorkspace}
onUpload={onUploadFile}
/>
</div>
);
}

View File

@@ -1,156 +0,0 @@
import React from "react";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import AgentState from "#/types/agent-state";
import ExplorerTree from "../../../components/file-explorer/explorer-tree";
import toast from "#/utils/toast";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { useListFiles } from "#/hooks/query/use-list-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { cn } from "#/utils/utils";
import { OpenVSCodeButton } from "./buttons/open-vscode-button";
import { Dropzone } from "./dropzone";
import { FileExplorerHeader } from "./file-explorer-header";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
interface FileExplorerProps {
isOpen: boolean;
onToggle: () => void;
}
export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
const { t } = useTranslation();
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { data: paths, refetch, error } = useListFiles();
const { mutate: uploadFiles } = useUploadFiles();
const { refetch: getVSCodeUrl } = useVSCodeUrl();
const selectFileInput = () => {
fileInputRef.current?.click(); // Trigger the file browser
};
const handleUploadSuccess = (data: FileUploadSuccessResponse) => {
const uploadedCount = data.uploaded_files.length;
const skippedCount = data.skipped_files.length;
if (uploadedCount > 0) {
toast.success(
`upload-success-${new Date().getTime()}`,
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
count: uploadedCount,
}),
);
}
if (skippedCount > 0) {
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
count: skippedCount,
});
toast.info(message);
}
if (uploadedCount === 0 && skippedCount === 0) {
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
}
};
const handleUploadError = (uploadError: Error) => {
toast.error(
`upload-error-${new Date().getTime()}`,
uploadError.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
);
};
const refreshWorkspace = () => {
if (
curAgentState !== AgentState.LOADING &&
curAgentState !== AgentState.STOPPED
) {
refetch();
}
};
const uploadFileData = (files: FileList) => {
uploadFiles(
{ files: Array.from(files) },
{ onSuccess: handleUploadSuccess, onError: handleUploadError },
);
refreshWorkspace();
};
const handleDropFiles = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const { files: droppedFiles } = event.dataTransfer;
if (droppedFiles.length > 0) {
uploadFileData(droppedFiles);
}
setIsDragging(false);
};
React.useEffect(() => {
refreshWorkspace();
}, [curAgentState]);
return (
<div
data-testid="file-explorer"
className="relative h-full"
onDragEnter={() => {
setIsDragging(true);
}}
onDragEnd={() => {
setIsDragging(false);
}}
>
{isDragging && (
<Dropzone
onDragLeave={() => setIsDragging(false)}
onDrop={handleDropFiles}
/>
)}
<div
className={cn(
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
!isOpen ? "w-12" : "w-60",
)}
>
<div className="flex flex-col relative h-full px-3 py-2 overflow-hidden">
<FileExplorerHeader
isOpen={isOpen}
onToggle={onToggle}
onRefreshWorkspace={refreshWorkspace}
onUploadFile={selectFileInput}
/>
{!error && (
<div className="overflow-auto flex-grow min-h-0">
<div style={{ display: !isOpen ? "none" : "block" }}>
<ExplorerTree files={paths || []} />
</div>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-neutral-300 text-sm">{error.message}</p>
</div>
)}
{isOpen && (
<OpenVSCodeButton
onClick={getVSCodeUrl}
isDisabled={
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING
}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -5,12 +5,23 @@ import { editor } from "monaco-editor";
import { EditorProps } from "@monaco-editor/react";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
import { FileExplorer } from "#/routes/_oh.app._index/file-explorer/file-explorer";
import FileExplorer from "#/components/file-explorer/file-explorer";
import CodeEditorComponent from "./code-editor-component";
import { useFiles } from "#/context/files";
import { EditorActions } from "#/components/editor-actions";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
import { ASSET_FILE_TYPES } from "./constants";
const ASSET_FILE_TYPES = [
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".pdf",
".mp4",
".webm",
".ogg",
];
export function ErrorBoundary() {
const error = useRouteError();

View File

@@ -2,7 +2,7 @@ import { useDisclosure } from "@nextui-org/react";
import React from "react";
import { Outlet } from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import Security from "#/components/modals/security/security";
import Security from "../components/modals/security/security";
import { Controls } from "#/components/controls";
import { RootState } from "#/store";
import { Container } from "#/components/container";
@@ -14,23 +14,18 @@ import GlobeIcon from "#/icons/globe.svg?react";
import ListIcon from "#/icons/list-type-number.svg?react";
import { clearJupyter } from "#/state/jupyter-slice";
import { FilesProvider } from "#/context/files";
import { ChatInterface } from "./chat-interface";
import { ChatInterface } from "#/components/chat-interface";
import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "./event-handler";
import { EventHandler } from "#/components/event-handler";
import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit";
import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
import { useCloseWarning } from "#/hooks/use-close-warning";
import { CloseWarningDialog } from "#/components/modals/close-warning-dialog";
function App() {
const { token, gitHubToken } = useAuth();
const { settings } = useUserPrefs();
const { showWarning, setShowWarning } = useCloseWarning();
const dispatch = useDispatch();
useConversationConfig();
const { selectedRepository } = useSelector(
(state: RootState) => state.initalQuery,
@@ -46,7 +41,7 @@ function App() {
);
const Terminal = React.useMemo(
() => React.lazy(() => import("#/components/terminal/terminal")),
() => React.lazy(() => import("../components/terminal/terminal")),
[],
);
@@ -116,11 +111,6 @@ function App() {
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
/>
<CloseWarningDialog
isOpen={showWarning}
onClose={() => setShowWarning(false)}
onConfirm={() => window.close()}
/>
</div>
</EventHandler>
</WsClientProvider>

View File

@@ -1,90 +0,0 @@
import posthog from "posthog-js";
import React from "react";
import { SuggestionItem } from "#/components/suggestion-item";
import { useAuth } from "#/context/auth-context";
import { downloadWorkspace } from "#/utils/download-workspace";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
}
export function ActionSuggestions({
onSuggestionsClick,
}: ActionSuggestionsProps) {
const { gitHubToken } = useAuth();
const [isDownloading, setIsDownloading] = React.useState(false);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const handleDownloadWorkspace = async () => {
setIsDownloading(true);
try {
await downloadWorkspace();
} catch (error) {
// TODO: Handle error
} finally {
setIsDownloading(false);
}
};
return (
<div className="flex flex-col gap-2 mb-2">
{gitHubToken ? (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
<SuggestionItem
suggestion={{
label: "Push to Branch",
value:
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request.",
}}
onClick={(value) => {
posthog.capture("push_to_branch_button_clicked");
onSuggestionsClick(value);
}}
/>
<SuggestionItem
suggestion={{
label: "Push & Create PR",
value:
"Please push the changes to GitHub and open a pull request.",
}}
onClick={(value) => {
posthog.capture("create_pr_button_clicked");
onSuggestionsClick(value);
setHasPullRequest(true);
}}
/>
</>
) : (
<SuggestionItem
suggestion={{
label: "Push changes to PR",
value:
"Please push the latest changes to the existing pull request.",
}}
onClick={(value) => {
posthog.capture("push_to_pr_button_clicked");
onSuggestionsClick(value);
}}
/>
)}
</div>
) : (
<SuggestionItem
suggestion={{
label: !isDownloading
? "Download .zip"
: "Downloading, please wait...",
value: "Download .zip",
}}
onClick={() => {
posthog.capture("download_workspace_button_clicked");
handleDownloadWorkspace();
}}
/>
)}
</div>
);
}

View File

@@ -1,144 +0,0 @@
import { useDispatch, useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { FeedbackActions } from "../../components/feedback-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "../../components/interactive-chat-box";
import { addUserMessage } from "#/state/chat-slice";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../../components/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import TypingIndicator from "../../components/chat/typing-indicator";
import { ContinueButton } from "../../components/continue-button";
import { ScrollToBottomButton } from "../../components/scroll-to-bottom-button";
import { useWsClient } from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { LoadingSpinner } from "./loading-spinner";
import { ChatSuggestions } from "./chat-suggestions";
import { ActionSuggestions } from "./action-suggestions";
export function ChatInterface() {
const { send, isLoadingMessages } = useWsClient();
const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);
const { messages } = useSelector((state: RootState) => state.chat);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"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));
};
const handleSendContinueMsg = () => {
handleSendMessage("Continue", []);
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
setFeedbackModalIsOpen(true);
setFeedbackPolarity(polarity);
};
const isWaitingForUserInput =
curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.FINISHED;
return (
<div className="h-full flex flex-col justify-between">
{messages.length === 0 && (
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
)}
<div
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{isLoadingMessages && <LoadingSpinner />}
{!isLoadingMessages && (
<Messages
messages={messages}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
/>
)}
{isWaitingForUserInput && (
<ActionSuggestions
onSuggestionsClick={(value) => handleSendMessage(value, [])}
/>
)}
</div>
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<FeedbackActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
/>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{messages.length > 2 &&
curAgentState === AgentState.AWAITING_USER_INPUT && (
<ContinueButton onClick={handleSendContinueMsg} />
)}
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
</div>
{!hitBottom && <ScrollToBottomButton onClick={scrollDomToBottom} />}
</div>
<InteractiveChatBox
onSubmit={handleSendMessage}
onStop={handleStop}
isDisabled={
curAgentState === AgentState.LOADING ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
value={messageToSend ?? undefined}
onChange={setMessageToSend}
/>
</div>
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
polarity={feedbackPolarity}
/>
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { Suggestions } from "#/components/suggestions";
import BuildIt from "#/icons/build-it.svg?react";
import { SUGGESTIONS } from "#/utils/suggestions";
interface ChatSuggestionsProps {
onSuggestionsClick: (value: string) => void;
}
export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
return (
<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&apos;s start building!
</span>
</div>
<Suggestions
suggestions={Object.entries(SUGGESTIONS.repo)
.slice(0, 4)
.map(([label, value]) => ({
label,
value,
}))}
onSuggestionClick={onSuggestionsClick}
/>
</div>
);
}

View File

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

View File

@@ -1,65 +0,0 @@
import React from "react";
import toast from "react-hot-toast";
import { useDispatch, useSelector } from "react-redux";
import { isGitHubErrorReponse } from "#/api/github";
import { useAuth } from "#/context/auth-context";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { getGitHubTokenCommand } from "#/services/terminal-service";
import { setImportedProjectZip } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
import { useGitHubUser } from "../../../hooks/query/use-github-user";
export const useHandleRuntimeActive = () => {
const { gitHubToken } = useAuth();
const { status, send } = useWsClient();
const dispatch = useDispatch();
const { data: user } = useGitHubUser();
const { mutate: uploadFiles } = useUploadFiles();
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
const { importedProjectZip } = useSelector(
(state: RootState) => state.initalQuery,
);
const userId = React.useMemo(() => {
if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
}, [user]);
const handleUploadFiles = (zip: string) => {
const blob = base64ToBlob(zip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
toast.error("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
};
React.useEffect(() => {
if (runtimeActive && userId && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(gitHubToken));
}
}, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
if (runtimeActive && importedProjectZip) {
handleUploadFiles(importedProjectZip);
}
}, [runtimeActive, importedProjectZip]);
};

View File

@@ -1,71 +0,0 @@
import React from "react";
import toast from "react-hot-toast";
import { useDispatch } from "react-redux";
import { useAuth } from "#/context/auth-context";
import { useWsClient } from "#/context/ws-client-provider";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { addErrorMessage } from "#/state/chat-slice";
import AgentState from "#/types/agent-state";
import { ErrorObservation } from "#/types/core/observations";
import { useEndSession } from "../../../hooks/use-end-session";
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 const useHandleWSEvents = () => {
const { events, send } = useWsClient();
const { setToken } = useAuth();
const endSession = useEndSession();
const dispatch = useDispatch();
React.useEffect(() => {
if (!events.length) {
return;
}
const event = events[events.length - 1];
if (event.token && typeof event.token === "string") {
setToken(event.token);
return;
}
if (isServerError(event)) {
if (event.error_code === 401) {
toast.error("Session expired.");
endSession();
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]);
};

View File

@@ -1,97 +0,0 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { useAuth } from "#/context/auth-context";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { createChatMessage } from "#/services/chat-service";
import { getCloneRepoCommand } from "#/services/terminal-service";
import { setCurrentAgentState } from "#/state/agent-slice";
import { addUserMessage } from "#/state/chat-slice";
import {
clearSelectedRepository,
clearFiles,
clearInitialQuery,
} from "#/state/initial-query-slice";
import { RootState } from "#/store";
import AgentState from "#/types/agent-state";
export const useWSStatusChange = () => {
const { send, status } = useWsClient();
const { gitHubToken } = useAuth();
const dispatch = useDispatch();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.initalQuery,
);
const { files, importedProjectZip, initialQuery } = useSelector(
(state: RootState) => state.initalQuery,
);
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const dispatchCloneRepoCommand = (ghToken: string, repository: string) => {
send(getCloneRepoCommand(ghToken, repository));
dispatch(clearSelectedRepository());
};
const dispatchInitialQuery = (query: string, additionalInfo: string) => {
if (additionalInfo) {
sendInitialQuery(`${query}\n\n[${additionalInfo}]`, files);
} else {
sendInitialQuery(query, files);
}
dispatch(clearFiles()); // reset selected files
dispatch(clearInitialQuery()); // reset initial query
};
const handleOnWSActive = () => {
let additionalInfo = "";
if (gitHubToken && selectedRepository) {
dispatchCloneRepoCommand(gitHubToken, selectedRepository);
additionalInfo = `Repository ${selectedRepository} has been cloned to /workspace. Please check the /workspace for files.`;
} else if (importedProjectZip) {
// if there's an uploaded project zip, add it to the chat
additionalInfo =
"Files have been uploaded. Please check the /workspace for files.";
}
if (initialQuery) {
dispatchInitialQuery(initialQuery, additionalInfo);
}
};
React.useEffect(() => {
if (statusRef.current === status) {
return; // This is a check because of strict mode - if the status did not change, don't do anything
}
statusRef.current = status;
if (status === WsClientProviderStatus.ACTIVE) {
handleOnWSActive();
}
if (status === WsClientProviderStatus.OPENING && initialQuery) {
dispatch(
addUserMessage({
content: initialQuery,
imageUrls: files,
timestamp: new Date().toISOString(),
}),
);
}
if (status === WsClientProviderStatus.STOPPED) {
dispatch(setCurrentAgentState(AgentState.STOPPED));
}
}, [status]);
};

View File

@@ -1,7 +0,0 @@
export function LoadingSpinner() {
return (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-t-[4px] border-primary-500 rounded-full animate-spin" />
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { ChatMessage } from "#/components/chat-message";
import ConfirmationButtons from "#/components/chat/confirmation-buttons";
import { ErrorMessage } from "#/components/error-message";
import { ImageCarousel } from "#/components/image-carousel";
const isErrorMessage = (
message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message;
interface MessagesProps {
messages: (Message | ErrorMessage)[];
isAwaitingUserConfirmation: boolean;
}
export function Messages({
messages,
isAwaitingUserConfirmation,
}: MessagesProps) {
return messages.map((message, index) =>
isErrorMessage(message) ? (
<ErrorMessage key={index} id={message.id} message={message.message} />
) : (
<ChatMessage key={index} type={message.sender} message={message.content}>
{message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation && <ConfirmationButtons />}
</ChatMessage>
),
);
}

268
frontend/src/routes/_oh.tsx Normal file
View File

@@ -0,0 +1,268 @@
import React from "react";
import {
useRouteError,
isRouteErrorResponse,
useLocation,
Outlet,
} from "@remix-run/react";
import { useDispatch } from "react-redux";
import CogTooth from "#/assets/cog-tooth";
import { SettingsForm } from "#/components/form/settings-form";
import AccountSettingsModal from "#/components/modals/account-settings-modal";
import { DangerModal } from "#/components/modals/confirmation-modals/danger-modal";
import { LoadingSpinner } from "#/components/modals/loading-project";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { UserActions } from "#/components/user-actions";
import i18n from "#/i18n";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import NewProjectIcon from "#/icons/new-project.svg?react";
import DocsIcon from "#/icons/docs.svg?react";
import { WaitlistModal } from "#/components/waitlist-modal";
import { AnalyticsConsentFormModal } from "#/components/analytics-consent-form-modal";
import { setCurrentAgentState } from "#/state/agent-slice";
import AgentState from "#/types/agent-state";
import { useConfig } from "#/hooks/query/use-config";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useAuth } from "#/context/auth-context";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserPrefs } from "#/context/user-prefs-context";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status}</h1>
<p>{error.statusText}</p>
<pre>
{error.data instanceof Object
? JSON.stringify(error.data)
: error.data}
</pre>
</div>
);
}
if (error instanceof Error) {
return (
<div>
<h1>Uh oh, an error occurred!</h1>
<pre>{error.message}</pre>
</div>
);
}
return (
<div>
<h1>Uh oh, an unknown error occurred!</h1>
</div>
);
}
export default function MainApp() {
const { token, gitHubToken, clearToken, logout } = useAuth();
const { settings, settingsAreUpToDate } = useUserPrefs();
const location = useLocation();
const dispatch = useDispatch();
const endSession = useEndSession();
// FIXME: Bad practice to use localStorage directly
const analyticsConsent = localStorage.getItem("analytics-consent");
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
React.useState(false);
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
!localStorage.getItem("analytics-consent"),
);
const config = useConfig();
const user = useGitHubUser();
const {
data: isAuthed,
isFetched,
isFetching: isFetchingAuth,
} = useIsAuthed();
const aiConfigOptions = useAIConfigOptions();
const gitHubAuthUrl = useGitHubAuthUrl({
gitHubToken,
appMode: config.data?.APP_MODE || null,
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
});
React.useEffect(() => {
if (isFetched && !isAuthed) clearToken();
}, [isFetched, isAuthed]);
React.useEffect(() => {
if (settings.LANGUAGE) {
i18n.changeLanguage(settings.LANGUAGE);
}
}, [settings.LANGUAGE]);
React.useEffect(() => {
// If the github token is invalid, open the account settings modal again
if (user.isError) {
setAccountSettingsModalOpen(true);
}
}, [user.isError]);
const handleAccountSettingsModalClose = () => {
// If the user closes the modal without connecting to GitHub,
// we need to log them out to clear the invalid token from the
// local storage
if (user.isError) logout();
setAccountSettingsModalOpen(false);
};
const handleEndSession = () => {
setStartNewProjectModalIsOpen(false);
dispatch(setCurrentAgentState(AgentState.LOADING));
endSession();
};
return (
<div
data-testid="root-layout"
className="bg-root-primary p-3 h-screen min-w-[1024px] overflow-x-hidden flex gap-3"
>
<aside className="px-1 flex flex-col gap-1">
<div className="w-[34px] h-[34px] flex items-center justify-center">
{user.isLoading && <LoadingSpinner size="small" />}
{!user.isLoading && (
<button
type="button"
aria-label="All Hands Logo"
onClick={() => {
if (location.pathname.startsWith("/app"))
setStartNewProjectModalIsOpen(true);
}}
>
<AllHandsLogo width={34} height={23} />
</button>
)}
</div>
<nav className="py-[18px] flex flex-col items-center gap-[18px]">
<UserActions
user={user.data ? { avatar_url: user.data.avatar_url } : undefined}
onLogout={logout}
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
/>
<button
type="button"
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
onClick={() => setSettingsModalIsOpen(true)}
aria-label="Settings"
>
<CogTooth />
</button>
<a
href="https://docs.all-hands.dev"
target="_blank"
rel="noreferrer noopener"
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
aria-label="Documentation"
>
<DocsIcon width={28} height={28} />
</a>
{!!token && (
<button
data-testid="new-project-button"
type="button"
aria-label="Start new project"
onClick={() => setStartNewProjectModalIsOpen(true)}
>
<NewProjectIcon width={28} height={28} />
</button>
)}
</nav>
</aside>
<div className="h-full w-full relative">
<Outlet />
</div>
{isAuthed && (!settingsAreUpToDate || settingsModalIsOpen) && (
<ModalBackdrop onClose={() => setSettingsModalIsOpen(false)}>
<div
data-testid="ai-config-modal"
className="bg-root-primary w-[384px] p-6 rounded-xl flex flex-col gap-2"
>
{aiConfigOptions.error && (
<p className="text-danger text-xs">
{aiConfigOptions.error.message}
</p>
)}
<span className="text-xl leading-6 font-semibold -tracking-[0.01em">
AI Provider Configuration
</span>
<p className="text-xs text-[#A3A3A3]">
To continue, connect an OpenAI, Anthropic, or other LLM account
</p>
<p className="text-xs text-danger">
Changing settings during an active session will end the session
</p>
{aiConfigOptions.isLoading && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{aiConfigOptions.data && (
<SettingsForm
settings={settings}
models={aiConfigOptions.data?.models}
agents={aiConfigOptions.data?.agents}
securityAnalyzers={aiConfigOptions.data?.securityAnalyzers}
onClose={() => {
setSettingsModalIsOpen(false);
}}
/>
)}
</div>
</ModalBackdrop>
)}
{accountSettingsModalOpen && (
<ModalBackdrop onClose={handleAccountSettingsModalClose}>
<AccountSettingsModal
onClose={handleAccountSettingsModalClose}
selectedLanguage={settings.LANGUAGE}
gitHubError={user.isError}
analyticsConsent={analyticsConsent}
/>
</ModalBackdrop>
)}
{startNewProjectModalIsOpen && (
<ModalBackdrop onClose={() => setStartNewProjectModalIsOpen(false)}>
<DangerModal
title="Are you sure you want to exit?"
description="You will lose any unsaved information."
buttons={{
danger: {
text: "Exit Project",
onClick: handleEndSession,
},
cancel: {
text: "Cancel",
onClick: () => setStartNewProjectModalIsOpen(false),
},
}}
/>
</ModalBackdrop>
)}
{!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas" && (
<WaitlistModal ghToken={gitHubToken} githubAuthUrl={gitHubAuthUrl} />
)}
{consentFormIsOpen && (
<AnalyticsConsentFormModal
onClose={() => setConsentFormIsOpen(false)}
/>
)}
</div>
);
}

View File

@@ -1,13 +0,0 @@
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
interface AllHandsLogoButtonProps {
onClick: () => void;
}
export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
return (
<button type="button" aria-label="All Hands Logo" onClick={onClick}>
<AllHandsLogo width={34} height={23} />
</button>
);
}

View File

@@ -1,15 +0,0 @@
import DocsIcon from "#/icons/docs.svg?react";
export function DocsButton() {
return (
<a
href="https://docs.all-hands.dev"
aria-label="Documentation"
target="_blank"
rel="noreferrer noopener"
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
>
<DocsIcon width={28} height={28} />
</a>
);
}

View File

@@ -1,18 +0,0 @@
import NewProjectIcon from "#/icons/new-project.svg?react";
interface ExitProjectButtonProps {
onClick: () => void;
}
export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
return (
<button
data-testid="new-project-button"
type="button"
aria-label="Start new project"
onClick={onClick}
>
<NewProjectIcon width={28} height={28} />
</button>
);
}

View File

@@ -1,18 +0,0 @@
import CogTooth from "#/assets/cog-tooth";
interface SettingsButtonProps {
onClick: () => void;
}
export function SettingsButton({ onClick }: SettingsButtonProps) {
return (
<button
type="button"
aria-label="Settings"
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
onClick={onClick}
>
<CogTooth />
</button>
);
}

View File

@@ -1,27 +0,0 @@
import { AccountSettingsForm } from "#/components/modals/account-settings-form";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { useUserPrefs } from "#/context/user-prefs-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
interface AccountSettingsModalProps {
onClose: () => void;
}
export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
const user = useGitHubUser();
const { settings } = useUserPrefs();
// FIXME: Bad practice to use localStorage directly
const analyticsConsent = localStorage.getItem("analytics-consent");
return (
<ModalBackdrop onClose={onClose}>
<AccountSettingsForm
onClose={onClose}
selectedLanguage={settings.LANGUAGE}
gitHubError={user.isError}
analyticsConsent={analyticsConsent}
/>
</ModalBackdrop>
);
}

View File

@@ -1,42 +0,0 @@
import { useDispatch } from "react-redux";
import { DangerModal } from "#/components/modals/confirmation-modals/danger-modal";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { useEndSession } from "#/hooks/use-end-session";
import { setCurrentAgentState } from "#/state/agent-slice";
import AgentState from "#/types/agent-state";
interface ExitProjectConfirmationModalProps {
onClose: () => void;
}
export function ExitProjectConfirmationModal({
onClose,
}: ExitProjectConfirmationModalProps) {
const dispatch = useDispatch();
const endSession = useEndSession();
const handleEndSession = () => {
onClose();
dispatch(setCurrentAgentState(AgentState.LOADING));
endSession();
};
return (
<ModalBackdrop onClose={onClose}>
<DangerModal
title="Are you sure you want to exit?"
description="You will lose any unsaved information."
buttons={{
danger: {
text: "Exit Project",
onClick: handleEndSession,
},
cancel: {
text: "Cancel",
onClick: onClose,
},
}}
/>
</ModalBackdrop>
);
}

View File

@@ -1,50 +0,0 @@
import { SettingsForm } from "#/components/form/settings-form";
import { LoadingSpinner } from "#/components/modals/loading-project";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
import { useUserPrefs } from "#/context/user-prefs-context";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
interface SettingsModalProps {
onClose: () => void;
}
export function SettingsModal({ onClose }: SettingsModalProps) {
const { settings } = useUserPrefs();
const aiConfigOptions = useAIConfigOptions();
return (
<ModalBackdrop onClose={onClose}>
<div
data-testid="ai-config-modal"
className="bg-root-primary w-[384px] p-6 rounded-xl flex flex-col gap-2"
>
{aiConfigOptions.error && (
<p className="text-danger text-xs">{aiConfigOptions.error.message}</p>
)}
<span className="text-xl leading-6 font-semibold -tracking-[0.01em">
AI Provider Configuration
</span>
<p className="text-xs text-[#A3A3A3]">
To continue, connect an OpenAI, Anthropic, or other LLM account
</p>
<p className="text-xs text-danger">
Changing settings during an active session will end the session
</p>
{aiConfigOptions.isLoading && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{aiConfigOptions.data && (
<SettingsForm
settings={settings}
models={aiConfigOptions.data?.models}
agents={aiConfigOptions.data?.agents}
securityAnalyzers={aiConfigOptions.data?.securityAnalyzers}
onClose={onClose}
/>
)}
</div>
</ModalBackdrop>
);
}

View File

@@ -1,100 +0,0 @@
import React from "react";
import { useRouteError, isRouteErrorResponse, Outlet } from "@remix-run/react";
import i18n from "#/i18n";
import { WaitlistModal } from "#/components/waitlist-modal";
import { AnalyticsConsentFormModal } from "#/components/analytics-consent-form-modal";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
import { Sidebar } from "./sidebar";
import { useConfig } from "#/hooks/query/use-config";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status}</h1>
<p>{error.statusText}</p>
<pre>
{error.data instanceof Object
? JSON.stringify(error.data)
: error.data}
</pre>
</div>
);
}
if (error instanceof Error) {
return (
<div>
<h1>Uh oh, an error occurred!</h1>
<pre>{error.message}</pre>
</div>
);
}
return (
<div>
<h1>Uh oh, an unknown error occurred!</h1>
</div>
);
}
export default function MainApp() {
const { gitHubToken, clearToken } = useAuth();
const { settings } = useUserPrefs();
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
!localStorage.getItem("analytics-consent"),
);
const config = useConfig();
const {
data: isAuthed,
isFetched,
isFetching: isFetchingAuth,
} = useIsAuthed();
const gitHubAuthUrl = useGitHubAuthUrl({
gitHubToken,
appMode: config.data?.APP_MODE || null,
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
});
React.useEffect(() => {
if (isFetched && !isAuthed) clearToken();
}, [isFetched, isAuthed]);
React.useEffect(() => {
if (settings.LANGUAGE) {
i18n.changeLanguage(settings.LANGUAGE);
}
}, [settings.LANGUAGE]);
const isInWaitlist =
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
return (
<div
data-testid="root-layout"
className="bg-root-primary p-3 h-screen min-w-[1024px] overflow-x-hidden flex gap-3"
>
<Sidebar />
<div className="h-full w-full relative">
<Outlet />
</div>
{isInWaitlist && (
<WaitlistModal ghToken={gitHubToken} githubAuthUrl={gitHubAuthUrl} />
)}
{consentFormIsOpen && (
<AnalyticsConsentFormModal
onClose={() => setConsentFormIsOpen(false)}
/>
)}
</div>
);
}

View File

@@ -1,91 +0,0 @@
import React from "react";
import { useLocation } from "react-router-dom";
import { LoadingSpinner } from "#/components/modals/loading-project";
import { UserActions } from "#/components/user-actions";
import { useAuth } from "#/context/auth-context";
import { useUserPrefs } from "#/context/user-prefs-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { SettingsModal } from "./modals/settings-modal";
import { ExitProjectConfirmationModal } from "./modals/exit-project-confirmation-modal";
import { AllHandsLogoButton } from "./buttons/all-hands-logo-button";
import { SettingsButton } from "./buttons/settings-button";
import { DocsButton } from "./buttons/docs-button";
import { ExitProjectButton } from "./buttons/exit-project-button";
import { AccountSettingsModal } from "./modals/account-settings-modal";
export function Sidebar() {
const location = useLocation();
const user = useGitHubUser();
const { data: isAuthed } = useIsAuthed();
const { token, logout } = useAuth();
const { settingsAreUpToDate } = useUserPrefs();
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
React.useState(false);
React.useEffect(() => {
// If the github token is invalid, open the account settings modal again
if (user.isError) {
setAccountSettingsModalOpen(true);
}
}, [user.isError]);
const handleAccountSettingsModalClose = () => {
// If the user closes the modal without connecting to GitHub,
// we need to log them out to clear the invalid token from the
// local storage
if (user.isError) logout();
setAccountSettingsModalOpen(false);
};
const handleClickLogo = () => {
if (location.pathname.startsWith("/app"))
setStartNewProjectModalIsOpen(true);
};
const showSettingsModal =
isAuthed && (!settingsAreUpToDate || settingsModalIsOpen);
return (
<>
<aside className="px-1 flex flex-col gap-1">
<div className="w-[34px] h-[34px] flex items-center justify-center">
{user.isLoading && <LoadingSpinner size="small" />}
{!user.isLoading && <AllHandsLogoButton onClick={handleClickLogo} />}
</div>
<nav className="py-[18px] flex flex-col items-center gap-[18px]">
<UserActions
user={user.data ? { avatar_url: user.data.avatar_url } : undefined}
onLogout={logout}
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
/>
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
<DocsButton />
{!!token && (
<ExitProjectButton
onClick={() => setStartNewProjectModalIsOpen(true)}
/>
)}
</nav>
</aside>
{accountSettingsModalOpen && (
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
)}
{showSettingsModal && (
<SettingsModal onClose={() => setSettingsModalIsOpen(false)} />
)}
{startNewProjectModalIsOpen && (
<ExitProjectConfirmationModal
onClose={() => setStartNewProjectModalIsOpen(false)}
/>
)}
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
appendSecurityAnalyzerInput,
} from "#/state/security-analyzer-slice";
import { setCurStatusMessage } from "#/state/status-slice";
import { addStepCost } from "#/state/cost-slice";
import store from "#/store";
import ActionType from "#/types/action-type";
import {
@@ -21,11 +22,7 @@ import { handleObservationMessage } from "./observations";
const messageActions = {
[ActionType.BROWSE]: (message: ActionMessage) => {
if (message.args.thought) {
store.dispatch(addAssistantMessage(message.args.thought));
} else {
store.dispatch(addAssistantMessage(message.message));
}
store.dispatch(addAssistantMessage(message.message));
},
[ActionType.BROWSE_INTERACTIVE]: (message: ActionMessage) => {
if (message.args.thought) {
@@ -152,6 +149,12 @@ export function handleAssistantMessage(message: Record<string, unknown>) {
handleObservationMessage(message as unknown as ObservationMessage);
} else if (message.status_update) {
handleStatusMessage(message as unknown as StatusMessage);
} else if (message.event === "cost") {
store.dispatch(addStepCost({
stepCost: message.step_cost as number,
totalCost: message.total_cost as number,
description: message.description as string,
}));
} else {
console.error("Unknown message type", message);
}

View File

@@ -1,6 +1,4 @@
export const LATEST_SETTINGS_VERSION = 4;
export type CloseWarningMode = 'always' | 'while_working' | 'never';
export const LATEST_SETTINGS_VERSION = 3;
export type Settings = {
LLM_MODEL: string;
@@ -10,7 +8,6 @@ export type Settings = {
LLM_API_KEY: string;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
CLOSE_WARNING: CloseWarningMode;
};
export const DEFAULT_SETTINGS: Settings = {
@@ -21,7 +18,6 @@ export const DEFAULT_SETTINGS: Settings = {
LLM_API_KEY: "",
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
CLOSE_WARNING: "while_working",
};
const validKeys = Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[];
@@ -57,9 +53,6 @@ export const maybeMigrateSettings = () => {
if (currentVersion < 3) {
localStorage.removeItem("token");
}
if (currentVersion < 4) {
localStorage.setItem("CLOSE_WARNING", DEFAULT_SETTINGS.CLOSE_WARNING);
}
};
/**
@@ -78,7 +71,6 @@ export const getSettings = (): Settings => {
const apiKey = localStorage.getItem("LLM_API_KEY");
const confirmationMode = localStorage.getItem("CONFIRMATION_MODE") === "true";
const securityAnalyzer = localStorage.getItem("SECURITY_ANALYZER");
const closeWarning = localStorage.getItem("CLOSE_WARNING") as CloseWarningMode;
return {
LLM_MODEL: model || DEFAULT_SETTINGS.LLM_MODEL,
@@ -88,7 +80,6 @@ export const getSettings = (): Settings => {
LLM_API_KEY: apiKey || DEFAULT_SETTINGS.LLM_API_KEY,
CONFIRMATION_MODE: confirmationMode || DEFAULT_SETTINGS.CONFIRMATION_MODE,
SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
CLOSE_WARNING: closeWarning || DEFAULT_SETTINGS.CLOSE_WARNING,
};
};

29
frontend/src/sessions.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { Settings } from "./services/settings";
type SessionData = {
tosAccepted: boolean;
ghToken: string;
token: string; // Session token
};
export const { getSession, commitSession, destroySession } =
createCookieSessionStorage<SessionData>({
cookie: {
name: "__session",
secrets: ["some_secret"],
},
});
type SettingsSessionData = { settings: Settings };
export const {
getSession: getSettingsSession,
commitSession: commitSettingsSession,
destroySession: destroySettingsSession,
} = createCookieSessionStorage<SettingsSessionData>({
cookie: {
name: "__settings",
secrets: ["some_other_secret"],
},
});

View File

@@ -0,0 +1,39 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type CostState = {
totalCost: number;
lastStepCosts: { cost: number; description: string }[];
};
const initialState: CostState = {
totalCost: 0,
lastStepCosts: [],
};
export const costSlice = createSlice({
name: "cost",
initialState,
reducers: {
addStepCost(
state,
action: PayloadAction<{ stepCost: number; totalCost: number; description: string }>,
) {
state.totalCost = action.payload.totalCost;
state.lastStepCosts.push({
cost: action.payload.stepCost,
description: action.payload.description,
});
// Keep only last 3 step costs
if (state.lastStepCosts.length > 3) {
state.lastStepCosts.shift();
}
},
clearCosts(state) {
state.totalCost = 0;
state.lastStepCosts = [];
},
},
});
export const { addStepCost, clearCosts } = costSlice.actions;
export default costSlice.reducer;

View File

@@ -3,6 +3,7 @@ import agentReducer from "./state/agent-slice";
import browserReducer from "./state/browser-slice";
import chatReducer from "./state/chat-slice";
import codeReducer from "./state/code-slice";
import costReducer from "./state/cost-slice";
import fileStateReducer from "./state/file-state-slice";
import initialQueryReducer from "./state/initial-query-slice";
import commandReducer from "./state/command-slice";
@@ -21,6 +22,7 @@ export const rootReducer = combineReducers({
jupyter: jupyterReducer,
securityAnalyzer: securityAnalyzerReducer,
status: statusReducer,
cost: costReducer,
});
const store = configureStore({

View File

@@ -1,7 +1,7 @@
/** Variances are types which do not conform to the current event pattern */
export interface TokenConfigSuccess {
status: "ok" | number;
status: "ok";
token: string;
}

View File

@@ -7,6 +7,7 @@ import { configureStore } from "@reduxjs/toolkit";
import { RenderOptions, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppStore, RootState, rootReducer } from "./src/store";
import { WsClientProvider } from "#/context/ws-client-provider";
import { AuthProvider } from "#/context/auth-context";
import { UserPrefsProvider } from "#/context/user-prefs-context";
@@ -40,7 +41,14 @@ export function renderWithProviders(
<UserPrefsProvider>
<AuthProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
<WsClientProvider
enabled
token={null}
ghToken={null}
settings={null}
>
{children}
</WsClientProvider>
</QueryClientProvider>
</AuthProvider>
</UserPrefsProvider>

View File

@@ -82,13 +82,6 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
secure: !INSECURE_SKIP_VERIFY,
},
"/socket.io": {
target: WS_URL,
ws: true,
changeOrigin: true,
secure: !INSECURE_SKIP_VERIFY,
//rewriteWsOrigin: true,
}
},
},
ssr: {

View File

@@ -15,7 +15,6 @@ from openhands.events.action import (
AgentDelegateAction,
AgentFinishAction,
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
IPythonRunCellAction,
@@ -152,7 +151,6 @@ class CodeActAgent(Agent):
IPythonRunCellAction,
FileEditAction,
BrowseInteractiveAction,
BrowseURLAction,
),
) or (
isinstance(action, (AgentFinishAction, CmdRunAction))
@@ -189,9 +187,7 @@ class CodeActAgent(Agent):
)
]
elif isinstance(action, CmdRunAction) and action.source == 'user':
content = [
TextContent(text=f'User executed the command:\n{action.command}')
]
content = [TextContent(text=f'User executed the command:\n{action.command}')]
return [
Message(
role='user',

View File

@@ -19,7 +19,6 @@ from openhands.events.action import (
AgentDelegateAction,
AgentFinishAction,
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
IPythonRunCellAction,
@@ -267,30 +266,6 @@ StrReplaceEditorTool = ChatCompletionToolParam(
),
)
_WEB_DESCRIPTION = """Read (convert to markdown) content from a webpage. You should prefer using the `webpage_read` tool over the `browser` tool, but do use the `browser` tool if you need to interact with a webpage (e.g., click a button, fill out a form, etc.).
You may use the `webpage_read` tool to read content from a webpage, and even search the webpage content using a Google search query (e.g., url=`https://www.google.com/search?q=YOUR_QUERY`).
"""
WebReadTool = ChatCompletionToolParam(
type='function',
function=ChatCompletionToolParamFunctionChunk(
name='web_read',
description=_WEB_DESCRIPTION,
parameters={
'type': 'object',
'properties': {
'url': {
'type': 'string',
'description': 'The URL of the webpage to read. You can also use a Google search query here (e.g., `https://www.google.com/search?q=YOUR_QUERY`).',
}
},
'required': ['url'],
},
),
)
# from browsergym/core/action/highlevel.py
_browser_action_space = HighLevelActionSet(
subsets=['bid', 'nav'],
@@ -299,7 +274,7 @@ _browser_action_space = HighLevelActionSet(
)
_BROWSER_DESCRIPTION = """Interact with the browser using Python code. Use it ONLY when you need to interact with a webpage.
_BROWSER_DESCRIPTION = """Interact with the browser using Python code.
See the description of "code" parameter for more details.
@@ -509,8 +484,6 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
action = IPythonRunCellAction(code=code, include_extra=False)
elif tool_call.function.name == 'browser':
action = BrowseInteractiveAction(browser_actions=arguments['code'])
elif tool_call.function.name == 'web_read':
action = BrowseURLAction(url=arguments['url'])
else:
raise FunctionCallNotExistsError(
f'Tool {tool_call.function.name} is not registered. (arguments: {arguments}). Please check the tool name and retry with an existing tool.'
@@ -543,7 +516,6 @@ def get_tools(
) -> list[ChatCompletionToolParam]:
tools = [CmdRunTool, FinishTool]
if codeact_enable_browsing:
tools.append(WebReadTool)
tools.append(BrowserTool)
if codeact_enable_jupyter:
tools.append(IPythonTool)

View File

@@ -36,6 +36,7 @@ from openhands.events.action import (
ModifyTaskAction,
NullAction,
)
from openhands.events.cost import CostEvent
from openhands.events.event import Event
from openhands.events.observation import (
AgentDelegateObservation,
@@ -102,11 +103,9 @@ class AgentController:
agent_configs: A dictionary mapping agent names to agent configurations in the case that
we delegate to a different agent.
sid: The session ID of the agent.
confirmation_mode: Whether to enable confirmation mode for agent actions.
initial_state: The initial state of the controller.
is_delegate: Whether this controller is a delegate.
headless_mode: Whether the agent is run in headless mode.
status_callback: Optional callback function to handle status updates.
"""
self._step_lock = asyncio.Lock()
self.id = sid
@@ -135,11 +134,10 @@ class AgentController:
self._stuck_detector = StuckDetector(self.state)
self.status_callback = status_callback
async def close(self) -> None:
async def close(self):
"""Closes the agent controller, canceling any ongoing tasks and unsubscribing from the event stream.
Note that it's fairly important that this closes properly, otherwise the state is incomplete.
"""
Note that it's fairly important that this closes properly, otherwise the state is incomplete."""
await self.set_agent_state_to(AgentState.STOPPED)
# we made history, now is the time to rewrite it!
@@ -168,13 +166,11 @@ class AgentController:
self.event_stream.unsubscribe(EventStreamSubscriber.AGENT_CONTROLLER, self.id)
self._closed = True
def log(self, level: str, message: str, extra: dict | None = None) -> None:
def log(self, level: str, message: str, extra: dict | None = None):
"""Logs a message to the agent controller's logger.
Args:
level (str): The logging level to use (e.g., 'info', 'debug', 'error').
message (str): The message to log.
extra (dict | None, optional): Additional fields to include in the log. Defaults to None.
"""
message = f'[Agent Controller {self.id}] {message}'
getattr(logger, level)(message, extra=extra, stacklevel=2)
@@ -186,6 +182,16 @@ class AgentController:
async def update_state_after_step(self):
# update metrics especially for cost. Use deepcopy to avoid it being modified by agent.reset()
self.state.local_metrics = copy.deepcopy(self.agent.llm.metrics)
# Emit cost event
if self.state.local_metrics.accumulated_cost is not None:
self.event_stream.add_event(
CostEvent(
step_cost=self.state.local_metrics.accumulated_cost - (self.state.metrics.accumulated_cost or 0),
total_cost=self.state.local_metrics.accumulated_cost,
description=f"Step {self.state.iteration}"
),
EventSource.ENVIRONMENT
)
async def _react_to_exception(
self,
@@ -200,10 +206,9 @@ class AgentController:
async def start_step_loop(self):
"""The main loop for the agent's step-by-step execution."""
self.log('info', 'Starting step loop...')
while True:
if not self._is_awaiting_observation() and not should_continue():
break
while should_continue():
if self._closed:
break
try:
@@ -218,7 +223,7 @@ class AgentController:
await asyncio.sleep(0.1)
async def on_event(self, event: Event) -> None:
async def on_event(self, event: Event):
"""Callback from the event stream. Notifies the controller of incoming events.
Args:
@@ -236,7 +241,7 @@ class AgentController:
elif isinstance(event, Observation):
await self._handle_observation(event)
async def _handle_action(self, action: Action) -> None:
async def _handle_action(self, action: Action):
"""Handles actions from the event stream.
Args:
@@ -263,7 +268,7 @@ class AgentController:
self.state.metrics.merge(self.state.local_metrics)
await self.set_agent_state_to(AgentState.REJECTED)
async def _handle_observation(self, observation: Observation) -> None:
async def _handle_observation(self, observation: Observation):
"""Handles observation from the event stream.
Args:
@@ -294,7 +299,7 @@ class AgentController:
if self.state.agent_state == AgentState.ERROR:
self.state.metrics.merge(self.state.local_metrics)
async def _handle_message_action(self, action: MessageAction) -> None:
async def _handle_message_action(self, action: MessageAction):
"""Handles message actions from the event stream.
Args:
@@ -315,12 +320,13 @@ class AgentController:
elif action.source == EventSource.AGENT and action.wait_for_response:
await self.set_agent_state_to(AgentState.AWAITING_USER_INPUT)
def reset_task(self) -> None:
def reset_task(self):
"""Resets the agent's task."""
self.almost_stuck = 0
self.agent.reset()
async def set_agent_state_to(self, new_state: AgentState) -> None:
async def set_agent_state_to(self, new_state: AgentState):
"""Updates the agent's state and handles side effects. Can emit events to the event stream.
Args:
@@ -381,7 +387,7 @@ class AgentController:
await self.set_agent_state_to(self.state.resume_state)
self.state.resume_state = None
def get_agent_state(self) -> AgentState:
def get_agent_state(self):
"""Returns the current state of the agent.
Returns:
@@ -389,7 +395,7 @@ class AgentController:
"""
return self.state.agent_state
async def start_delegate(self, action: AgentDelegateAction) -> None:
async def start_delegate(self, action: AgentDelegateAction):
"""Start a delegate agent to handle a subtask.
OpenHands is a multi-agentic system. A `task` is a conversation between
@@ -537,7 +543,7 @@ class AgentController:
log_level = 'info' if LOG_ALL_EVENTS else 'debug'
self.log(log_level, str(action), extra={'msg_type': 'ACTION'})
async def _delegate_step(self) -> None:
async def _delegate_step(self):
"""Executes a single step of the delegate agent."""
await self.delegate._step() # type: ignore[union-attr]
assert self.delegate is not None
@@ -601,7 +607,7 @@ class AgentController:
async def _handle_traffic_control(
self, limit_type: str, current_value: float, max_value: float
) -> bool:
):
"""Handles agent state after hitting the traffic control limit.
Args:
@@ -633,7 +639,7 @@ class AgentController:
stop_step = True
return stop_step
def get_state(self) -> State:
def get_state(self):
"""Returns the current running state object.
Returns:
@@ -646,7 +652,7 @@ class AgentController:
state: State | None,
max_iterations: int,
confirmation_mode: bool = False,
) -> None:
):
"""Sets the initial state for the agent, either from the previous session, or from a parent agent, or by creating a new one.
Args:
@@ -677,7 +683,7 @@ class AgentController:
self._init_history()
def _init_history(self) -> None:
def _init_history(self):
"""Initializes the agent's history from the event stream.
The history is a list of events that:
@@ -693,6 +699,7 @@ class AgentController:
Otherwise loads normally from start_id.
"""
# define range of events to fetch
# delegates start with a start_id and initially won't find any events
# otherwise we're restoring a previous session
@@ -888,7 +895,7 @@ class AgentController:
return kept_events
def _is_stuck(self) -> bool:
def _is_stuck(self):
"""Checks if the agent or its delegate is stuck in a loop.
Returns:
@@ -907,11 +914,3 @@ class AgentController:
f'state={self.state!r}, agent_task={self.agent_task!r}, '
f'delegate={self.delegate!r}, _pending_action={self._pending_action!r})'
)
def _is_awaiting_observation(self):
events = self.event_stream.get_events(reverse=True)
for event in events:
if isinstance(event, AgentStateChangedObservation):
result = event.agent_state == AgentState.RUNNING
return result
return False

View File

@@ -37,7 +37,6 @@ class SandboxConfig:
remote_runtime_api_url: str = 'http://localhost:8000'
local_runtime_url: str = 'http://localhost'
keep_runtime_alive: bool = True
rm_all_containers: bool = False
api_key: str | None = None
base_container_image: str = 'nikolaik/python-nodejs:python3.12-nodejs22' # default to nikolaik/python-nodejs:python3.12-nodejs22 for eventstream runtime
runtime_container_image: str | None = None
@@ -54,7 +53,6 @@ class SandboxConfig:
runtime_startup_env_vars: dict[str, str] = field(default_factory=dict)
browsergym_eval_env: str | None = None
platform: str | None = None
close_delay: int = 15
def defaults_to_dict(self) -> dict:
"""Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""

View File

@@ -15,7 +15,7 @@ class BrowseURLAction(Action):
@property
def message(self) -> str:
return f'I am browsing the URL: {self.url}'
return f'Browsing URL: {self.url}'
def __str__(self) -> str:
ret = '**BrowseURLAction**\n'

View File

@@ -24,7 +24,6 @@ class MessageAction(Action):
@images_urls.setter
def images_urls(self, value):
self.image_urls = value
def __str__(self) -> str:
ret = f'**MessageAction** (source={self.source})\n'
ret += f'CONTENT: {self.content}'

21
openhands/events/cost.py Normal file
View File

@@ -0,0 +1,21 @@
from dataclasses import dataclass
from openhands.events.observation import Observation
@dataclass
class CostEvent(Observation):
"""Event emitted when a cost is incurred by the LLM."""
step_cost: float
total_cost: float
description: str
def __init__(self, step_cost: float, total_cost: float, description: str):
super().__init__(content="") # Content will be set in post_init
self.step_cost = step_cost
self.total_cost = total_cost
self.description = description
def __post_init__(self):
super().__post_init__()
self.observation = "cost"
self.content = f"Cost: ${self.step_cost:.4f} (Total: ${self.total_cost:.4f}) - {self.description}"

View File

@@ -2,7 +2,7 @@ from dataclasses import dataclass, field
from browsergym.utils.obs import flatten_axtree_to_str
from openhands.core.schema import ActionType, ObservationType
from openhands.core.schema import ObservationType
from openhands.events.observation.observation import Observation
@@ -11,7 +11,6 @@ class BrowserOutputObservation(Observation):
"""This data class represents the output of a browser."""
url: str
trigger_by_action: str
screenshot: str = field(repr=False) # don't show in repr
error: bool = False
observation: str = ObservationType.BROWSE
@@ -41,6 +40,7 @@ class BrowserOutputObservation(Observation):
f'Last browser action: {self.last_browser_action}\n'
f'Last browser action error: {self.last_browser_action_error}\n'
f'Focused element bid: {self.focused_element_bid}\n'
f'Content: {self.content}\n'
)
ret += '--- Agent Observation ---\n'
ret += self.get_agent_obs_text()
@@ -48,49 +48,31 @@ class BrowserOutputObservation(Observation):
def get_agent_obs_text(self) -> str:
"""Get a concise text that will be shown to the agent."""
if self.trigger_by_action == ActionType.BROWSE_INTERACTIVE:
text = f'[Current URL: {self.url}]\n'
text += f'[Focused element bid: {self.focused_element_bid}]\n\n'
if self.error:
text += (
'================ BEGIN error message ===============\n'
'The following error occurred when executing the last action:\n'
f'{self.last_browser_action_error}\n'
'================ END error message ===============\n'
)
else:
text += '[Action executed successfully.]\n'
try:
# We do not filter visible only here because we want to show the full content
# of the web page to the agent for simplicity.
# FIXME: handle the case when the web page is too large
cur_axtree_txt = self.get_axtree_str(filter_visible_only=False)
text += (
f'============== BEGIN accessibility tree ==============\n'
f'{cur_axtree_txt}\n'
f'============== END accessibility tree ==============\n'
)
except Exception as e:
text += (
f'\n[Error encountered when processing the accessibility tree: {e}]'
)
return text
elif self.trigger_by_action == ActionType.BROWSE:
text = f'[Current URL: {self.url}]\n'
if self.error:
text += (
'================ BEGIN error message ===============\n'
'The following error occurred when trying to visit the URL:\n'
f'{self.last_browser_action_error}\n'
'================ END error message ===============\n'
)
text += '============== BEGIN webpage content ==============\n'
text += self.content
text += '\n============== END webpage content ==============\n'
return text
text = f'[Current URL: {self.url}]\n'
text += f'[Focused element bid: {self.focused_element_bid}]\n\n'
if self.error:
text += (
'================ BEGIN error message ===============\n'
'The following error occurred when executing the last action:\n'
f'{self.last_browser_action_error}\n'
'================ END error message ===============\n'
)
else:
raise ValueError(f'Invalid trigger_by_action: {self.trigger_by_action}')
text += '[Action executed successfully.]\n'
try:
# We do not filter visible only here because we want to show the full content
# of the web page to the agent for simplicity.
# FIXME: handle the case when the web page is too large
cur_axtree_txt = self.get_axtree_str(filter_visible_only=False)
text += (
f'============== BEGIN accessibility tree ==============\n'
f'{cur_axtree_txt}\n'
f'============== END accessibility tree ==============\n'
)
except Exception as e:
text += f'\n[Error encountered when processing the accessibility tree: {e}]'
return text
def get_axtree_str(self, filter_visible_only: bool = False) -> str:
cur_axtree_txt = flatten_axtree_to_str(

View File

@@ -69,7 +69,7 @@ def action_from_dict(action: dict) -> Action:
# images_urls has been renamed to image_urls
if 'images_urls' in args:
args['image_urls'] = args.pop('images_urls')
try:
decoded_action = action_class(**args)
if 'timeout' in action:

View File

@@ -211,95 +211,6 @@ class EventStream:
if event.source == source:
yield event
def _should_filter_event(
self,
event,
query: str | None = None,
event_type: str | None = None,
source: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> bool:
"""Check if an event should be filtered out based on the given criteria.
Args:
event: The event to check
query (str, optional): Text to search for in event content
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
source (str, optional): Filter by event source
start_date (str, optional): Filter events after this date (ISO format)
end_date (str, optional): Filter events before this date (ISO format)
Returns:
bool: True if the event should be filtered out, False if it matches all criteria
"""
if event_type and not event.__class__.__name__ == event_type:
return True
if source and not event.source.value == source:
return True
if start_date and event.timestamp < start_date:
return True
if end_date and event.timestamp > end_date:
return True
# Text search in event content if query provided
if query:
event_dict = event_to_dict(event)
event_str = str(event_dict).lower()
if query.lower() not in event_str:
return True
return False
def get_matching_events(
self,
query: str | None = None,
event_type: str | None = None,
source: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
start_id: int = 0,
limit: int = 100,
) -> list:
"""Get matching events from the event stream based on filters.
Args:
query (str, optional): Text to search for in event content
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
source (str, optional): Filter by event source
start_date (str, optional): Filter events after this date (ISO format)
end_date (str, optional): Filter events before this date (ISO format)
start_id (int): Starting ID in the event stream. Defaults to 0
limit (int): Maximum number of events to return. Must be between 1 and 100. Defaults to 100
Returns:
list: List of matching events (as dicts)
Raises:
ValueError: If limit is less than 1 or greater than 100
"""
if limit < 1 or limit > 100:
raise ValueError('Limit must be between 1 and 100')
matching_events: list = []
for event in self.get_events(start_id=start_id):
if self._should_filter_event(
event, query, event_type, source, start_date, end_date
):
continue
matching_events.append(event_to_dict(event))
# Stop if we have enough events
if len(matching_events) >= limit:
break
return matching_events
def clear(self):
self.file_store.delete(f'sessions/{self.sid}')
self._cur_id = 0

View File

@@ -12,7 +12,6 @@ from openhands.core.config import LLMConfig
with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm
from litellm import Message as LiteLLMMessage
from litellm import ModelInfo, PromptTokensDetails
from litellm import completion as litellm_completion
@@ -66,7 +65,6 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
'claude-3-5-sonnet',
'claude-3-5-sonnet-20240620',
'claude-3-5-sonnet-20241022',
'claude-3.5-haiku',
'claude-3-5-haiku-20241022',
'gpt-4o-mini',
'gpt-4o',
@@ -245,13 +243,7 @@ class LLM(RetryMixin, DebugMixin):
with open(log_file, 'w') as f:
f.write(json.dumps(_d))
message_back: str = resp['choices'][0]['message']['content'] or ''
tool_calls = resp['choices'][0]['message'].get('tool_calls', [])
if tool_calls:
for tool_call in tool_calls:
fn_name = tool_call.function.name
fn_args = tool_call.function.arguments
message_back += f'\nFunction call: {fn_name}({fn_args})'
message_back: str = resp['choices'][0]['message']['content']
# log the LLM response
self.log_response(message_back)

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from .apply import apply_diff
from .patch import parse_patch
from .apply import apply_diff
__all__ = ['parse_patch', 'apply_diff']
__all__ = ["parse_patch", "apply_diff"]

View File

@@ -10,33 +10,33 @@ from .snippets import remove, which
def _apply_diff_with_subprocess(diff, lines, reverse=False):
# call out to patch program
patchexec = which('patch')
patchexec = which("patch")
if not patchexec:
raise SubprocessException('cannot find patch program', code=-1)
raise SubprocessException("cannot find patch program", code=-1)
tempdir = tempfile.gettempdir()
filepath = os.path.join(tempdir, 'wtp-' + str(hash(diff.header)))
oldfilepath = filepath + '.old'
newfilepath = filepath + '.new'
rejfilepath = filepath + '.rej'
patchfilepath = filepath + '.patch'
with open(oldfilepath, 'w') as f:
f.write('\n'.join(lines) + '\n')
filepath = os.path.join(tempdir, "wtp-" + str(hash(diff.header)))
oldfilepath = filepath + ".old"
newfilepath = filepath + ".new"
rejfilepath = filepath + ".rej"
patchfilepath = filepath + ".patch"
with open(oldfilepath, "w") as f:
f.write("\n".join(lines) + "\n")
with open(patchfilepath, 'w') as f:
with open(patchfilepath, "w") as f:
f.write(diff.text)
args = [
patchexec,
'--reverse' if reverse else '--forward',
'--quiet',
'--no-backup-if-mismatch',
'-o',
"--reverse" if reverse else "--forward",
"--quiet",
"--no-backup-if-mismatch",
"-o",
newfilepath,
'-i',
"-i",
patchfilepath,
'-r',
"-r",
rejfilepath,
oldfilepath,
]
@@ -58,7 +58,7 @@ def _apply_diff_with_subprocess(diff, lines, reverse=False):
# do this last to ensure files get cleaned up
if ret != 0:
raise SubprocessException('patch program failed', code=ret)
raise SubprocessException("patch program failed", code=ret)
return lines, rejlines

View File

@@ -7,7 +7,7 @@ class HunkException(PatchingException):
self.hunk = hunk
if hunk is not None:
super(HunkException, self).__init__(
'{msg}, in hunk #{n}'.format(msg=msg, n=hunk)
"{msg}, in hunk #{n}".format(msg=msg, n=hunk)
)
else:
super(HunkException, self).__init__(msg)

View File

@@ -8,67 +8,67 @@ from . import exceptions
from .snippets import findall_regex, split_by_regex
header = namedtuple(
'header',
'index_path old_path old_version new_path new_version',
"header",
"index_path old_path old_version new_path new_version",
)
diffobj = namedtuple('diffobj', 'header changes text')
Change = namedtuple('Change', 'old new line hunk')
diffobj = namedtuple("diffobj", "header changes text")
Change = namedtuple("Change", "old new line hunk")
file_timestamp_str = '(.+?)(?:\t|:| +)(.*)'
file_timestamp_str = "(.+?)(?:\t|:| +)(.*)"
# .+? was previously [^:\t\n\r\f\v]+
# general diff regex
diffcmd_header = re.compile('^diff.* (.+) (.+)$')
unified_header_index = re.compile('^Index: (.+)$')
unified_header_old_line = re.compile(r'^--- ' + file_timestamp_str + '$')
unified_header_new_line = re.compile(r'^\+\+\+ ' + file_timestamp_str + '$')
unified_hunk_start = re.compile(r'^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)$')
unified_change = re.compile('^([-+ ])(.*)$')
diffcmd_header = re.compile("^diff.* (.+) (.+)$")
unified_header_index = re.compile("^Index: (.+)$")
unified_header_old_line = re.compile(r"^--- " + file_timestamp_str + "$")
unified_header_new_line = re.compile(r"^\+\+\+ " + file_timestamp_str + "$")
unified_hunk_start = re.compile(r"^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)$")
unified_change = re.compile("^([-+ ])(.*)$")
context_header_old_line = re.compile(r'^\*\*\* ' + file_timestamp_str + '$')
context_header_new_line = re.compile('^--- ' + file_timestamp_str + '$')
context_hunk_start = re.compile(r'^\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*$')
context_hunk_old = re.compile(r'^\*\*\* (\d+),?(\d*) \*\*\*\*$')
context_hunk_new = re.compile(r'^--- (\d+),?(\d*) ----$')
context_change = re.compile('^([-+ !]) (.*)$')
context_header_old_line = re.compile(r"^\*\*\* " + file_timestamp_str + "$")
context_header_new_line = re.compile("^--- " + file_timestamp_str + "$")
context_hunk_start = re.compile(r"^\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*$")
context_hunk_old = re.compile(r"^\*\*\* (\d+),?(\d*) \*\*\*\*$")
context_hunk_new = re.compile(r"^--- (\d+),?(\d*) ----$")
context_change = re.compile("^([-+ !]) (.*)$")
ed_hunk_start = re.compile(r'^(\d+),?(\d*)([acd])$')
ed_hunk_end = re.compile('^.$')
ed_hunk_start = re.compile(r"^(\d+),?(\d*)([acd])$")
ed_hunk_end = re.compile("^.$")
# much like forward ed, but no 'c' type
rcs_ed_hunk_start = re.compile(r'^([ad])(\d+) ?(\d*)$')
rcs_ed_hunk_start = re.compile(r"^([ad])(\d+) ?(\d*)$")
default_hunk_start = re.compile(r'^(\d+),?(\d*)([acd])(\d+),?(\d*)$')
default_hunk_mid = re.compile('^---$')
default_change = re.compile('^([><]) (.*)$')
default_hunk_start = re.compile(r"^(\d+),?(\d*)([acd])(\d+),?(\d*)$")
default_hunk_mid = re.compile("^---$")
default_change = re.compile("^([><]) (.*)$")
# Headers
# git has a special index header and no end part
git_diffcmd_header = re.compile('^diff --git a/(.+) b/(.+)$')
git_header_index = re.compile(r'^index ([a-f0-9]+)..([a-f0-9]+) ?(\d*)$')
git_header_old_line = re.compile('^--- (.+)$')
git_header_new_line = re.compile(r'^\+\+\+ (.+)$')
git_header_file_mode = re.compile(r'^(new|deleted) file mode \d{6}$')
git_header_binary_file = re.compile('^Binary files (.+) and (.+) differ')
git_binary_patch_start = re.compile(r'^GIT binary patch$')
git_binary_literal_start = re.compile(r'^literal (\d+)$')
git_binary_delta_start = re.compile(r'^delta (\d+)$')
base85string = re.compile(r'^[0-9A-Za-z!#$%&()*+;<=>?@^_`{|}~-]+$')
git_diffcmd_header = re.compile("^diff --git a/(.+) b/(.+)$")
git_header_index = re.compile(r"^index ([a-f0-9]+)..([a-f0-9]+) ?(\d*)$")
git_header_old_line = re.compile("^--- (.+)$")
git_header_new_line = re.compile(r"^\+\+\+ (.+)$")
git_header_file_mode = re.compile(r"^(new|deleted) file mode \d{6}$")
git_header_binary_file = re.compile("^Binary files (.+) and (.+) differ")
git_binary_patch_start = re.compile(r"^GIT binary patch$")
git_binary_literal_start = re.compile(r"^literal (\d+)$")
git_binary_delta_start = re.compile(r"^delta (\d+)$")
base85string = re.compile(r"^[0-9A-Za-z!#$%&()*+;<=>?@^_`{|}~-]+$")
bzr_header_index = re.compile('=== (.+)')
bzr_header_index = re.compile("=== (.+)")
bzr_header_old_line = unified_header_old_line
bzr_header_new_line = unified_header_new_line
svn_header_index = unified_header_index
svn_header_timestamp_version = re.compile(r'\((?:working copy|revision (\d+))\)')
svn_header_timestamp = re.compile(r'.*(\(.*\))$')
svn_header_timestamp_version = re.compile(r"\((?:working copy|revision (\d+))\)")
svn_header_timestamp = re.compile(r".*(\(.*\))$")
cvs_header_index = unified_header_index
cvs_header_rcs = re.compile(r'^RCS file: (.+)(?:,\w{1}$|$)')
cvs_header_timestamp = re.compile(r'(.+)\t([\d.]+)')
cvs_header_timestamp_colon = re.compile(r':([\d.]+)\t(.+)')
old_cvs_diffcmd_header = re.compile('^diff.* (.+):(.*) (.+):(.*)$')
cvs_header_rcs = re.compile(r"^RCS file: (.+)(?:,\w{1}$|$)")
cvs_header_timestamp = re.compile(r"(.+)\t([\d.]+)")
cvs_header_timestamp_colon = re.compile(r":([\d.]+)\t(.+)")
old_cvs_diffcmd_header = re.compile("^diff.* (.+):(.*) (.+):(.*)$")
def parse_patch(text):
@@ -97,7 +97,7 @@ def parse_patch(text):
break
for diff in diffs:
difftext = '\n'.join(diff) + '\n'
difftext = "\n".join(diff) + "\n"
h = parse_header(diff)
d = parse_diff(diff)
if h or d:
@@ -133,10 +133,10 @@ def parse_scm_header(text):
if res:
old_path = res.old_path
new_path = res.new_path
if old_path.startswith('a/'):
if old_path.startswith("a/"):
old_path = old_path[2:]
if new_path.startswith('b/'):
if new_path.startswith("b/"):
new_path = new_path[2:]
return header(
@@ -240,10 +240,10 @@ def parse_git_header(text):
new_path = binary.group(2)
if old_path and new_path:
if old_path.startswith('a/'):
if old_path.startswith("a/"):
old_path = old_path[2:]
if new_path.startswith('b/'):
if new_path.startswith("b/"):
new_path = new_path[2:]
return header(
index_path=None,
@@ -256,19 +256,19 @@ def parse_git_header(text):
# if we go through all of the text without finding our normal info,
# use the cmd if available
if cmd_old_path and cmd_new_path and old_version and new_version:
if cmd_old_path.startswith('a/'):
if cmd_old_path.startswith("a/"):
cmd_old_path = cmd_old_path[2:]
if cmd_new_path.startswith('b/'):
if cmd_new_path.startswith("b/"):
cmd_new_path = cmd_new_path[2:]
return header(
index_path=None,
# wow, I kind of hate this:
# assume /dev/null if the versions are zeroed out
old_path='/dev/null' if old_version == '0000000' else cmd_old_path,
old_path="/dev/null" if old_version == "0000000" else cmd_old_path,
old_version=old_version,
new_path='/dev/null' if new_version == '0000000' else cmd_new_path,
new_path="/dev/null" if new_version == "0000000" else cmd_new_path,
new_version=new_version,
)
@@ -569,10 +569,10 @@ def parse_default_diff(text):
kind = c.group(1)
line = c.group(2)
if kind == '<' and (r != old_len or r == 0):
if kind == "<" and (r != old_len or r == 0):
changes.append(Change(old + r, None, line, hunk_n))
r += 1
elif kind == '>' and (i != new_len or i == 0):
elif kind == ">" and (i != new_len or i == 0):
changes.append(Change(None, new + i, line, hunk_n))
i += 1
@@ -627,13 +627,13 @@ def parse_unified_diff(text):
kind = c.group(1)
line = c.group(2)
if kind == '-' and (r != old_len or r == 0):
if kind == "-" and (r != old_len or r == 0):
changes.append(Change(old + r, None, line, hunk_n))
r += 1
elif kind == '+' and (i != new_len or i == 0):
elif kind == "+" and (i != new_len or i == 0):
changes.append(Change(None, new + i, line, hunk_n))
i += 1
elif kind == ' ':
elif kind == " ":
if r != old_len and i != new_len:
changes.append(Change(old + r, new + i, line, hunk_n))
r += 1
@@ -667,7 +667,7 @@ def parse_context_diff(text):
k = 0
parts = split_by_regex(hunk, context_hunk_new)
if len(parts) != 2:
raise exceptions.ParseException('Context diff invalid', hunk_n)
raise exceptions.ParseException("Context diff invalid", hunk_n)
old_hunk = parts[0]
new_hunk = parts[1]
@@ -695,7 +695,7 @@ def parse_context_diff(text):
# now have old and new set, can start processing?
if len(old_hunk) > 0 and len(new_hunk) == 0:
msg = 'Got unexpected change in removal hunk: '
msg = "Got unexpected change in removal hunk: "
# only removes left?
while len(old_hunk) > 0:
c = context_change.match(old_hunk[0])
@@ -707,22 +707,22 @@ def parse_context_diff(text):
kind = c.group(1)
line = c.group(2)
if kind == '-' and (j != old_len or j == 0):
if kind == "-" and (j != old_len or j == 0):
changes.append(Change(old + j, None, line, hunk_n))
j += 1
elif kind == ' ' and (
elif kind == " " and (
(j != old_len and k != new_len) or (j == 0 or k == 0)
):
changes.append(Change(old + j, new + k, line, hunk_n))
j += 1
k += 1
elif kind == '+' or kind == '!':
elif kind == "+" or kind == "!":
raise exceptions.ParseException(msg + kind, hunk_n)
continue
if len(old_hunk) == 0 and len(new_hunk) > 0:
msg = 'Got unexpected change in removal hunk: '
msg = "Got unexpected change in removal hunk: "
# only insertions left?
while len(new_hunk) > 0:
c = context_change.match(new_hunk[0])
@@ -734,16 +734,16 @@ def parse_context_diff(text):
kind = c.group(1)
line = c.group(2)
if kind == '+' and (k != new_len or k == 0):
if kind == "+" and (k != new_len or k == 0):
changes.append(Change(None, new + k, line, hunk_n))
k += 1
elif kind == ' ' and (
elif kind == " " and (
(j != old_len and k != new_len) or (j == 0 or k == 0)
):
changes.append(Change(old + j, new + k, line, hunk_n))
j += 1
k += 1
elif kind == '-' or kind == '!':
elif kind == "-" or kind == "!":
raise exceptions.ParseException(msg + kind, hunk_n)
continue
@@ -765,17 +765,17 @@ def parse_context_diff(text):
if not (oc or nc):
del old_hunk[0]
del new_hunk[0]
elif okind == ' ' and nkind == ' ' and oline == nline:
elif okind == " " and nkind == " " and oline == nline:
changes.append(Change(old + j, new + k, oline, hunk_n))
j += 1
k += 1
del old_hunk[0]
del new_hunk[0]
elif okind == '-' or okind == '!' and (j != old_len or j == 0):
elif okind == "-" or okind == "!" and (j != old_len or j == 0):
changes.append(Change(old + j, None, oline, hunk_n))
j += 1
del old_hunk[0]
elif nkind == '+' or nkind == '!' and (k != new_len or k == 0):
elif nkind == "+" or nkind == "!" and (k != new_len or k == 0):
changes.append(Change(None, new + k, nline, hunk_n))
k += 1
del new_hunk[0]
@@ -821,7 +821,7 @@ def parse_ed_diff(text):
old_end = int(o.group(2)) if len(o.group(2)) else old
hunk_kind = o.group(3)
if hunk_kind == 'd':
if hunk_kind == "d":
k = 0
while old_end >= old:
changes.append(Change(old + k, None, None, hunk_n))
@@ -832,7 +832,7 @@ def parse_ed_diff(text):
while len(hunk) > 0:
e = ed_hunk_end.match(hunk[0])
if not e and hunk_kind == 'c':
if not e and hunk_kind == "c":
k = 0
while old_end >= old:
changes.append(Change(old + k, None, None, hunk_n))
@@ -852,7 +852,7 @@ def parse_ed_diff(text):
)
i += 1
j += 1
if not e and hunk_kind == 'a':
if not e and hunk_kind == "a":
changes.append(
Change(
None,
@@ -900,7 +900,7 @@ def parse_rcs_ed_diff(text):
old = int(o.group(2))
size = int(o.group(3))
if hunk_kind == 'a':
if hunk_kind == "a":
old += total_change_size + 1
total_change_size += size
while size > 0 and len(hunk) > 0:
@@ -910,7 +910,7 @@ def parse_rcs_ed_diff(text):
del hunk[0]
elif hunk_kind == 'd':
elif hunk_kind == "d":
total_change_size -= size
while size > 0:
changes.append(Change(old + j, None, None, hunk_n))
@@ -938,8 +938,8 @@ def parse_git_binary_diff(text):
# the sizes are used as latch-up
new_size = 0
old_size = 0
old_encoded = ''
new_encoded = ''
old_encoded = ""
new_encoded = ""
for line in lines:
if cmd_old_path is None and cmd_new_path is None:
hm = git_diffcmd_header.match(line)
@@ -978,11 +978,11 @@ def parse_git_binary_diff(text):
change = Change(None, 0, added_data, None)
changes.append(change)
new_size = 0
new_encoded = ''
new_encoded = ""
else:
# Invalid line format
new_size = 0
new_encoded = ''
new_encoded = ""
# the second is removed file
if old_size == 0:
@@ -1006,10 +1006,10 @@ def parse_git_binary_diff(text):
change = Change(0, None, None, removed_data)
changes.append(change)
old_size = 0
old_encoded = ''
old_encoded = ""
else:
# Invalid line format
old_size = 0
old_encoded = ''
old_encoded = ""
return changes

View File

@@ -54,7 +54,7 @@ def which(program):
if is_exe(program):
return program
else:
for path in os.environ['PATH'].split(os.pathsep):
for path in os.environ["PATH"].split(os.pathsep):
path = path.strip('"')
exe_file = os.path.join(path, program)
if is_exe(exe_file):

View File

@@ -1,4 +1,4 @@
This is a Python repo for openhands-resolver, a library that attempts to resolve github issues with the AI agent OpenHands.
- Setup: `poetry install --with test --with dev`
- Testing: `poetry run pytest tests/test_*.py`
- Testing: `poetry run pytest tests/test_*.py`

View File

@@ -1,4 +1,4 @@
This is a node repo for an RSS parser.
- Setup: `yes | npm install`
- Testing: `SKIP_BROWSER_TESTS=1 npm test`
- Writing Tests: Add to the `test` directory.
- Writing Tests: Add to the `test` directory.

View File

@@ -14,4 +14,4 @@ For all changes to actual application code (e.g. in Python or Javascript), add a
Run the tests, and if they pass you are done!
You do NOT need to write new tests if there are only changes to documentation or configuration files.
When you think you have fixed the issue through code changes, please call the finish action to end the interaction.
When you think you have fixed the issue through code changes, please call the finish action to end the interaction.

View File

@@ -10,4 +10,4 @@ You SHOULD INCLUDE PROPER INDENTATION in your edit commands.{% if repo_instructi
Some basic information about this repository:
{{ repo_instruction }}{% endif %}
When you think you have fixed the issue through code changes, please finish the interaction.
When you think you have fixed the issue through code changes, please finish the interaction.

View File

@@ -196,11 +196,7 @@ class Runtime(FileEditRuntimeMixin):
e, RuntimeDisconnectedError
):
err_id = 'STATUS$ERROR_RUNTIME_DISCONNECTED'
logger.error(
'Unexpected error while running action',
exc_info=True,
stack_info=True,
)
self.log('error', f'Unexpected error while running action {e}')
self.log('error', f'Problematic action: {str(event)}')
self.send_error_message(err_id, str(e))
self.close()

View File

@@ -49,7 +49,6 @@ async def browse(
), # last browser env action performed
last_browser_action_error=obs.get('last_action_error', ''),
error=True if obs.get('last_action_error', '') else False, # error flag
trigger_by_action=action.action,
)
except Exception as e:
return BrowserOutputObservation(
@@ -58,5 +57,4 @@ async def browse(
error=True,
last_browser_action_error=str(e),
url=asked_url if action.action == ActionType.BROWSE else '',
trigger_by_action=action.action,
)

View File

@@ -456,7 +456,7 @@ class EventStreamRuntime(Runtime):
):
pass
def close(self, rm_all_containers: bool | None = None):
def close(self, rm_all_containers: bool = True):
"""Closes the EventStreamRuntime and associated objects
Parameters:
@@ -468,9 +468,6 @@ class EventStreamRuntime(Runtime):
if self.session:
self.session.close()
if rm_all_containers is None:
rm_all_containers = self.config.sandbox.rm_all_containers
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
return
close_prefix = (

View File

@@ -10,7 +10,6 @@ import requests
import tenacity
from openhands.core.config import AppConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.events.action import (
BrowseInteractiveAction,
@@ -34,7 +33,6 @@ from openhands.runtime.base import (
RuntimeDisconnectedError,
RuntimeNotFoundError,
RuntimeNotReadyError,
RuntimeUnavailableError,
)
from openhands.runtime.builder.remote import RemoteRuntimeBuilder
from openhands.runtime.plugins import PluginRequirement
@@ -248,21 +246,17 @@ class RemoteRuntime(Runtime):
}
# Start the sandbox using the /start endpoint
try:
with self._send_request(
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/start',
is_retry=False,
json=start_request,
) as response:
self._parse_runtime_response(response)
self.log(
'debug',
f'Runtime started. URL: {self.runtime_url}',
)
except requests.HTTPError as e:
self.log('error', f'Unable to start runtime: {e}')
raise RuntimeUnavailableError() from e
with self._send_request(
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/start',
is_retry=False,
json=start_request,
) as response:
self._parse_runtime_response(response)
self.log(
'debug',
f'Runtime started. URL: {self.runtime_url}',
)
def _resume_runtime(self):
with self._send_request(

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