mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
openhands-
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48cad4117a |
23
.github/workflows/eval-runner.yml
vendored
23
.github/workflows/eval-runner.yml
vendored
@@ -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
|
||||
|
||||
158
.github/workflows/integration-runner.yml
vendored
158
.github/workflows/integration-runner.yml
vendored
@@ -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 }})
|
||||
4
Makefile
4
Makefile
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
BIN
docs/static/img/teaser.mp4
vendored
BIN
docs/static/img/teaser.mp4
vendored
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}',
|
||||
)
|
||||
|
||||
@@ -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}',
|
||||
)
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
84
frontend/package-lock.json
generated
84
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
"APP_MODE": "oss",
|
||||
"GITHUB_CLIENT_ID": "",
|
||||
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
|
||||
}
|
||||
}
|
||||
245
frontend/src/components/chat-interface.tsx
Normal file
245
frontend/src/components/chat-interface.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/cost-display.tsx
Normal file
31
frontend/src/components/cost-display.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
frontend/src/components/event-handler.tsx
Normal file
193
frontend/src/components/event-handler.tsx
Normal 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;
|
||||
}
|
||||
307
frontend/src/components/file-explorer/file-explorer.tsx
Normal file
307
frontend/src/components/file-explorer/file-explorer.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export const ASSET_FILE_TYPES = [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".bmp",
|
||||
".gif",
|
||||
".pdf",
|
||||
".mp4",
|
||||
".webm",
|
||||
".ogg",
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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's start building!
|
||||
</span>
|
||||
</div>
|
||||
<Suggestions
|
||||
suggestions={Object.entries(SUGGESTIONS.repo)
|
||||
.slice(0, 4)
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
onSuggestionClick={onSuggestionsClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
268
frontend/src/routes/_oh.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
29
frontend/src/sessions.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
39
frontend/src/state/cost-slice.ts
Normal file
39
frontend/src/state/cost-slice.ts
Normal 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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
21
openhands/events/cost.py
Normal 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}"
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user