mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
39 Commits
move-githu
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98d68711df | ||
|
|
4fa5c329d6 | ||
|
|
5b1dcf83a6 | ||
|
|
b9884f7609 | ||
|
|
99eda0e571 | ||
|
|
5832463088 | ||
|
|
045ec2b95d | ||
|
|
23473070b9 | ||
|
|
63133c0ba9 | ||
|
|
2023fb767f | ||
|
|
23f40a1c01 | ||
|
|
873dddb4e8 | ||
|
|
fe50cd1f9f | ||
|
|
516e2da520 | ||
|
|
1dd6f544bc | ||
|
|
40c52feb5b | ||
|
|
f31ccad48b | ||
|
|
828d169b82 | ||
|
|
a622d27016 | ||
|
|
5507b131fe | ||
|
|
0f102e4c71 | ||
|
|
157a1a24f6 | ||
|
|
fcfbcb64d4 | ||
|
|
931792e87a | ||
|
|
ee701eacc2 | ||
|
|
8907fed78e | ||
|
|
3cc20a2576 | ||
|
|
01cf0d433c | ||
|
|
f6bed82ae2 | ||
|
|
3eae2e2aca | ||
|
|
b45fc522c7 | ||
|
|
0d409c8c24 | ||
|
|
5458ebbd7d | ||
|
|
c411a29db4 | ||
|
|
386e04a2ba | ||
|
|
62c4bab6ba | ||
|
|
e308b6fb6f | ||
|
|
27a660fb6b | ||
|
|
27d761a1fe |
@@ -1,4 +1,4 @@
|
||||
#
|
||||
|
||||
services:
|
||||
openhands:
|
||||
build:
|
||||
@@ -7,8 +7,8 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.19-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ~/.openhands-state:/.openhands-state
|
||||
- ${WORKSPACE_BASE:-$PWD/workspace}:/opt/workspace_base
|
||||
pull_policy: build
|
||||
stdin_open: true
|
||||
@@ -7,53 +7,11 @@ If you are running in [GUI Mode](https://docs.all-hands.dev/modules/usage/how-to
|
||||
take precedence.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
# Table of Contents
|
||||
|
||||
- [Core Configuration](#core-configuration)
|
||||
- [API Keys](#api-keys)
|
||||
- [Workspace](#workspace)
|
||||
- [Debugging and Logging](#debugging-and-logging)
|
||||
- [Session Management](#session-management)
|
||||
- [Trajectories](#trajectories)
|
||||
- [File Store](#file-store)
|
||||
- [Task Management](#task-management)
|
||||
- [Sandbox Configuration](#sandbox-configuration)
|
||||
- [Miscellaneous](#miscellaneous)
|
||||
- [LLM Configuration](#llm-configuration)
|
||||
- [AWS Credentials](#aws-credentials)
|
||||
- [API Configuration](#api-configuration)
|
||||
- [Custom LLM Provider](#custom-llm-provider)
|
||||
- [Embeddings](#embeddings)
|
||||
- [Message Handling](#message-handling)
|
||||
- [Model Selection](#model-selection)
|
||||
- [Retrying](#retrying)
|
||||
- [Advanced Options](#advanced-options)
|
||||
- [Agent Configuration](#agent-configuration)
|
||||
- [Microagent Configuration](#microagent-configuration)
|
||||
- [Memory Configuration](#memory-configuration)
|
||||
- [LLM Configuration](#llm-configuration-2)
|
||||
- [ActionSpace Configuration](#actionspace-configuration)
|
||||
- [Microagent Usage](#microagent-usage)
|
||||
- [Sandbox Configuration](#sandbox-configuration)
|
||||
- [Execution](#execution)
|
||||
- [Container Image](#container-image)
|
||||
- [Networking](#networking)
|
||||
- [Linting and Plugins](#linting-and-plugins)
|
||||
- [Dependencies and Environment](#dependencies-and-environment)
|
||||
- [Evaluation](#evaluation)
|
||||
- [Security Configuration](#security-configuration)
|
||||
- [Confirmation Mode](#confirmation-mode)
|
||||
- [Security Analyzer](#security-analyzer)
|
||||
|
||||
---
|
||||
|
||||
## Core Configuration
|
||||
|
||||
The core configuration options are defined in the `[core]` section of the `config.toml` file.
|
||||
|
||||
**API Keys**
|
||||
### API Keys
|
||||
- `e2b_api_key`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -69,7 +27,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `""`
|
||||
- Description: API token secret for Modal
|
||||
|
||||
**Workspace**
|
||||
### Workspace
|
||||
- `workspace_base`
|
||||
- Type: `str`
|
||||
- Default: `"./workspace"`
|
||||
@@ -80,7 +38,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `"/tmp/cache"`
|
||||
- Description: Cache directory path
|
||||
|
||||
**Debugging and Logging**
|
||||
### Debugging and Logging
|
||||
- `debug`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
@@ -91,13 +49,13 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `false`
|
||||
- Description: Disable color in terminal output
|
||||
|
||||
**Trajectories**
|
||||
### Trajectories
|
||||
- `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
|
||||
- `file_store_path`
|
||||
- Type: `str`
|
||||
- Default: `"/tmp/file_store"`
|
||||
@@ -128,7 +86,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `[".*"]`
|
||||
- Description: List of allowed file extensions for uploads
|
||||
|
||||
**Task Management**
|
||||
### Task Management
|
||||
- `max_budget_per_task`
|
||||
- Type: `float`
|
||||
- Default: `0.0`
|
||||
@@ -139,7 +97,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `100`
|
||||
- Description: Maximum number of iterations
|
||||
|
||||
**Sandbox Configuration**
|
||||
### Sandbox Configuration
|
||||
- `workspace_mount_path_in_sandbox`
|
||||
- Type: `str`
|
||||
- Default: `"/workspace"`
|
||||
@@ -155,7 +113,7 @@ The core configuration options are defined in the `[core]` section of the `confi
|
||||
- Default: `""`
|
||||
- Description: Path to rewrite the workspace mount path to. You can usually ignore this, it refers to special cases of running inside another container.
|
||||
|
||||
**Miscellaneous**
|
||||
### Miscellaneous
|
||||
- `run_as_openhands`
|
||||
- Type: `bool`
|
||||
- Default: `true`
|
||||
@@ -182,7 +140,7 @@ The LLM (Large Language Model) configuration options are defined in the `[llm]`
|
||||
|
||||
To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LLM_NUM_RETRIES`.
|
||||
|
||||
**AWS Credentials**
|
||||
### AWS Credentials
|
||||
- `aws_access_key_id`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -198,7 +156,7 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `""`
|
||||
- Description: AWS secret access key
|
||||
|
||||
**API Configuration**
|
||||
### API Configuration
|
||||
- `api_key`
|
||||
- Type: `str`
|
||||
- Default: `None`
|
||||
@@ -224,13 +182,13 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `0.0`
|
||||
- Description: Cost per output token
|
||||
|
||||
**Custom LLM Provider**
|
||||
### Custom LLM Provider
|
||||
- `custom_llm_provider`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: Custom LLM provider
|
||||
|
||||
**Embeddings**
|
||||
### Embeddings
|
||||
- `embedding_base_url`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -246,7 +204,7 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `"local"`
|
||||
- Description: Embedding model to use
|
||||
|
||||
**Message Handling**
|
||||
### Message Handling
|
||||
- `max_message_chars`
|
||||
- Type: `int`
|
||||
- Default: `30000`
|
||||
@@ -262,13 +220,13 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `0`
|
||||
- Description: Maximum number of output tokens
|
||||
|
||||
**Model Selection**
|
||||
### Model Selection
|
||||
- `model`
|
||||
- Type: `str`
|
||||
- Default: `"claude-3-5-sonnet-20241022"`
|
||||
- Description: Model to use
|
||||
|
||||
**Retrying**
|
||||
### Retrying
|
||||
- `num_retries`
|
||||
- Type: `int`
|
||||
- Default: `8`
|
||||
@@ -289,7 +247,7 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
- Default: `2.0`
|
||||
- Description: Multiplier for exponential backoff calculation
|
||||
|
||||
**Advanced Options**
|
||||
### Advanced Options
|
||||
- `drop_params`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
@@ -329,13 +287,13 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
|
||||
|
||||
The agent configuration options are defined in the `[agent]` and `[agent.<agent_name>]` sections of the `config.toml` file.
|
||||
|
||||
**Microagent Configuration**
|
||||
### Microagent Configuration
|
||||
- `micro_agent_name`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: Name of the micro agent to use for this agent
|
||||
|
||||
**Memory Configuration**
|
||||
### Memory Configuration
|
||||
- `memory_enabled`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
@@ -346,13 +304,13 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
|
||||
- Default: `3`
|
||||
- Description: The maximum number of threads indexing at the same time for embeddings
|
||||
|
||||
**LLM Configuration**
|
||||
### LLM Configuration
|
||||
- `llm_config`
|
||||
- Type: `str`
|
||||
- Default: `'your-llm-config-group'`
|
||||
- Description: The name of the LLM config to use
|
||||
|
||||
**ActionSpace Configuration**
|
||||
### ActionSpace Configuration
|
||||
- `function_calling`
|
||||
- Type: `bool`
|
||||
- Default: `true`
|
||||
@@ -373,7 +331,7 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
|
||||
- Default: `false`
|
||||
- Description: Whether Jupyter is enabled in the action space
|
||||
|
||||
**Microagent Usage**
|
||||
### Microagent Usage
|
||||
- `use_microagents`
|
||||
- Type: `bool`
|
||||
- Default: `true`
|
||||
@@ -390,7 +348,7 @@ The sandbox configuration options are defined in the `[sandbox]` section of the
|
||||
|
||||
To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-e SANDBOX_TIMEOUT`.
|
||||
|
||||
**Execution**
|
||||
### Execution
|
||||
- `timeout`
|
||||
- Type: `int`
|
||||
- Default: `120`
|
||||
@@ -401,19 +359,19 @@ To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-
|
||||
- Default: `1000`
|
||||
- Description: Sandbox user ID
|
||||
|
||||
**Container Image**
|
||||
### Container Image
|
||||
- `base_container_image`
|
||||
- Type: `str`
|
||||
- Default: `"nikolaik/python-nodejs:python3.12-nodejs22"`
|
||||
- Description: Container image to use for the sandbox
|
||||
|
||||
**Networking**
|
||||
### Networking
|
||||
- `use_host_network`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
- Description: Use host network
|
||||
|
||||
**Linting and Plugins**
|
||||
### Linting and Plugins
|
||||
- `enable_auto_lint`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
@@ -424,7 +382,7 @@ To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-
|
||||
- Default: `true`
|
||||
- Description: Whether to initialize plugins
|
||||
|
||||
**Dependencies and Environment**
|
||||
### Dependencies and Environment
|
||||
- `runtime_extra_deps`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -435,7 +393,7 @@ To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-
|
||||
- Default: `{}`
|
||||
- Description: Environment variables to set at the launch of the runtime
|
||||
|
||||
**Evaluation**
|
||||
### Evaluation
|
||||
- `browsergym_eval_env`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
@@ -447,13 +405,13 @@ The security configuration options are defined in the `[security]` section of th
|
||||
|
||||
To use these with the docker command, pass in `-e SECURITY_<option>`. Example: `-e SECURITY_CONFIRMATION_MODE`.
|
||||
|
||||
**Confirmation Mode**
|
||||
### Confirmation Mode
|
||||
- `confirmation_mode`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
- Description: Enable confirmation mode
|
||||
|
||||
**Security Analyzer**
|
||||
### Security Analyzer
|
||||
- `security_analyzer`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
|
||||
@@ -58,7 +58,7 @@ Here are some examples of CLI commands and their expected outputs:
|
||||
### Example 1: Simple Task
|
||||
|
||||
```bash
|
||||
How can I help? >> Write a Python script that prints "Hello, World!"
|
||||
>> Write a Python script that prints "Hello, World!"
|
||||
```
|
||||
|
||||
Expected Output:
|
||||
@@ -72,7 +72,7 @@ Expected Output:
|
||||
### Example 2: Bash Command
|
||||
|
||||
```bash
|
||||
How can I help? >> Create a directory named "test_dir"
|
||||
>> Create a directory named "test_dir"
|
||||
```
|
||||
|
||||
Expected Output:
|
||||
@@ -86,7 +86,7 @@ Expected Output:
|
||||
### Example 3: Error Handling
|
||||
|
||||
```bash
|
||||
How can I help? >> Delete a non-existent file
|
||||
>> Delete a non-existent file
|
||||
```
|
||||
|
||||
Expected Output:
|
||||
|
||||
@@ -76,18 +76,18 @@ When using OpenHands in online mode, the GitHub OAuth flow:
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
1. **Token Not Recognized**:
|
||||
- **Token Not Recognized**:
|
||||
- Ensure the token is properly saved in settings.
|
||||
- Check that the token hasn't expired.
|
||||
- Verify the token has the required scopes.
|
||||
- Try regenerating the token.
|
||||
|
||||
2. **Organization Access Denied**:
|
||||
- **Organization Access Denied**:
|
||||
- Check if SSO is required but not enabled.
|
||||
- Verify organization membership.
|
||||
- Contact organization admin if token policies are blocking access.
|
||||
|
||||
3. **Verifying Token Works**:
|
||||
- **Verifying Token Works**:
|
||||
- The app will show a green checkmark if the token is valid.
|
||||
- Try accessing a repository to confirm permissions.
|
||||
- Check the browser console for any error messages.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Persisting Session Data
|
||||
|
||||
Using the standard Development Workflow, the session data is stored in memory. Currently, if OpenHands' service is restarted,
|
||||
previous sessions become invalid (a new secret is generated) and thus not recoverable.
|
||||
|
||||
## How to Persist Session Data
|
||||
|
||||
### Development Workflow
|
||||
In the `config.toml` file, specify the following:
|
||||
```
|
||||
[core]
|
||||
...
|
||||
file_store="local"
|
||||
file_store_path="/absolute/path/to/openhands/cache/directory"
|
||||
jwt_secret="secretpass"
|
||||
```
|
||||
@@ -5,23 +5,14 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
|
||||
## Model Recommendations
|
||||
|
||||
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some
|
||||
recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and
|
||||
[this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent).
|
||||
|
||||
When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings:
|
||||
|
||||
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
|
||||
- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task.
|
||||
- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models.
|
||||
|
||||
Please refer to the [full article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) for more details.
|
||||
recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
|
||||
|
||||
Based on these findings and community feedback, the following models have been verified to work reasonably well with OpenHands:
|
||||
|
||||
- claude-3-5-sonnet (recommended)
|
||||
- gpt-4 / gpt-4o
|
||||
- llama-3.1-405b
|
||||
- deepseek-v2.5
|
||||
- anthropic/claude-3-5-sonnet-20241022 (recommended)
|
||||
- anthropic/claude-3-5-haiku-20241022
|
||||
- deepseek/deepseek-chat
|
||||
- gpt-4o
|
||||
|
||||
:::warning
|
||||
OpenHands will issue many prompts to the LLM you configure. Most of these LLMs cost money, so be sure to set spending
|
||||
|
||||
36
docs/modules/usage/prompting/microagents-overview.md
Normal file
36
docs/modules/usage/prompting/microagents-overview.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Microagents Overview
|
||||
|
||||
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge, repository-specific context
|
||||
and task-specific workflows. They help by providing expert guidance, automating common tasks, and ensuring
|
||||
consistent practices across projects.
|
||||
|
||||
## Microagent Types
|
||||
|
||||
Currently OpenHands supports the following types of microagents:
|
||||
|
||||
* [Repository Microagents](./microagents-repo): Repository-specific context and guidelines for OpenHands.
|
||||
* [Public Microagents](./microagents-public): General guidelines triggered by keywords for all OpenHands users.
|
||||
|
||||
When OpenHands works with a repository, it:
|
||||
|
||||
1. Loads repository-specific instructions from `.openhands/microagents/` if present in the repository.
|
||||
2. Loads general guidelines triggered by keywords in conversations.
|
||||
See current [Public Microagents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
|
||||
|
||||
## Microagent Format
|
||||
|
||||
All microagents use markdown files with YAML frontmatter that have special instructions to help OpenHands accomplish
|
||||
tasks:
|
||||
```
|
||||
---
|
||||
name: <Name of the microagent>
|
||||
type: <MicroAgent type>
|
||||
version: <MicroAgent version>
|
||||
agent: <The agent type (Typically CodeActAgent)>
|
||||
triggers:
|
||||
- <Optional keywords triggering the microagent. If triggers are removed, it will always be included>
|
||||
---
|
||||
|
||||
<Markdown with any special guidelines, instructions, and prompts that OpenHands should follow.
|
||||
Check out the specific documentation for each microagent on best practices for more information.>
|
||||
```
|
||||
@@ -1,31 +1,21 @@
|
||||
# Public Micro-Agents
|
||||
|
||||
OpenHands uses specialized micro-agents to handle specific tasks and contexts efficiently. These micro-agents are small,
|
||||
focused components that provide specialized behavior and knowledge for particular scenarios.
|
||||
# Public Microagents
|
||||
|
||||
## Overview
|
||||
|
||||
Public micro-agents are defined in markdown files under the
|
||||
Public microagents are specialized guidelines triggered by keywords for all OpenHands users.
|
||||
They are defined in markdown files under the
|
||||
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) directory.
|
||||
Each micro-agent is configured with:
|
||||
|
||||
- A unique name.
|
||||
- The agent type (typically CodeActAgent).
|
||||
- Trigger keywords that activate the agent.
|
||||
- Specific instructions and capabilities.
|
||||
|
||||
### Integration
|
||||
|
||||
Public micro-agents are automatically integrated into OpenHands' workflow. They:
|
||||
Public microagents:
|
||||
- Monitor incoming commands for their trigger words.
|
||||
- Activate when relevant triggers are detected.
|
||||
- Apply their specialized knowledge and capabilities.
|
||||
- Follow their specific guidelines and restrictions.
|
||||
|
||||
## Available Public Micro-Agents
|
||||
## Current Public Microagents
|
||||
|
||||
For more information about specific micro-agents, refer to their individual documentation files in
|
||||
the [`micro-agents`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents) directory.
|
||||
For more information about specific microagents, refer to their individual documentation files in
|
||||
the [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/) directory.
|
||||
|
||||
### GitHub Agent
|
||||
**File**: `github.md`
|
||||
@@ -66,105 +56,54 @@ Usage Example:
|
||||
yes | npm install package-name
|
||||
```
|
||||
|
||||
### Custom Public Micro-Agents
|
||||
## Contributing a Public Microagent
|
||||
|
||||
You can create your own public micro-agents by adding new markdown files to the `microagents/knowledge/` directory.
|
||||
Each file should follow this structure:
|
||||
You can create your own public microagents by adding new markdown files to the
|
||||
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/) directory.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: agent_name
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- trigger_word1
|
||||
- trigger_word2
|
||||
---
|
||||
### Public Microagents Best Practices
|
||||
|
||||
Instructions and capabilities for the micro-agent...
|
||||
```
|
||||
|
||||
## Working With Public Micro-Agents
|
||||
|
||||
When working with public micro-agents:
|
||||
- **Use Appropriate Triggers**: Ensure your commands include the relevant trigger words to activate the correct micro-agent.
|
||||
- **Follow Agent Guidelines**: Each agent has specific instructions and limitations. Respect these for optimal results.
|
||||
- **API-First Approach**: When available, use API endpoints rather than web interfaces.
|
||||
- **Automation Friendly**: Design commands that work well in non-interactive environments.
|
||||
|
||||
## Contributing a Public Micro-Agent
|
||||
|
||||
Best practices for creating public micro-agents:
|
||||
|
||||
- **Clear Scope**: Keep the micro-agent focused on a specific domain or task.
|
||||
- **Clear Scope**: Keep the microagent focused on a specific domain or task.
|
||||
- **Explicit Instructions**: Provide clear, unambiguous guidelines.
|
||||
- **Useful Examples**: Include practical examples of common use cases.
|
||||
- **Safety First**: Include necessary warnings and constraints.
|
||||
- **Integration Awareness**: Consider how the micro-agent interacts with other components.
|
||||
- **Integration Awareness**: Consider how the microagent interacts with other components.
|
||||
|
||||
To contribute a new micro-agent to OpenHands:
|
||||
### Steps to Contribute a Public Microagent
|
||||
|
||||
### 1. Plan the Public Micro-Agent
|
||||
#### 1. Plan the Public Microagent
|
||||
|
||||
Before creating a public micro-agent, consider:
|
||||
Before creating a public microagent, consider:
|
||||
- What specific problem or use case will it address?
|
||||
- What unique capabilities or knowledge should it have?
|
||||
- What trigger words make sense for activating it?
|
||||
- What constraints or guidelines should it follow?
|
||||
|
||||
### 2. File Structure
|
||||
#### 2. Create File
|
||||
|
||||
Create a new markdown file in `microagents/knowledge/` with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
|
||||
Create a new markdown file in [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/)
|
||||
with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
|
||||
|
||||
### 3. Required Components
|
||||
Update the file with the required frontmatter [according to the required format](./microagents-overview#microagent-format)
|
||||
and the required specialized guidelines while following the [best practices above](#public-microagents-best-practices).
|
||||
|
||||
The micro-agent file must include:
|
||||
#### 3. Testing the Public Microagent
|
||||
|
||||
- **Front Matter**: YAML metadata at the start of the file:
|
||||
```markdown
|
||||
---
|
||||
name: your_agent_name
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- trigger_word1
|
||||
- trigger_word2
|
||||
---
|
||||
```
|
||||
|
||||
- **Instructions**: Clear, specific guidelines for the agent's behavior:
|
||||
```markdown
|
||||
You are responsible for [specific task/domain].
|
||||
|
||||
Key responsibilities:
|
||||
1. [Responsibility 1]
|
||||
2. [Responsibility 2]
|
||||
|
||||
Guidelines:
|
||||
- [Guideline 1]
|
||||
- [Guideline 2]
|
||||
|
||||
Examples of usage:
|
||||
[Example 1]
|
||||
[Example 2]
|
||||
```
|
||||
|
||||
### 4. Testing the Public Micro-Agent
|
||||
|
||||
Before submitting:
|
||||
- Test the agent with various prompts.
|
||||
- Verify trigger words activate the agent correctly.
|
||||
- Ensure instructions are clear and comprehensive.
|
||||
- Check for potential conflicts with existing agents.
|
||||
|
||||
### 5. Submission Process
|
||||
#### 4. Submission Process
|
||||
|
||||
Submit a pull request with:
|
||||
- The new micro-agent file.
|
||||
- The new microagent file.
|
||||
- Updated documentation if needed.
|
||||
- Description of the agent's purpose and capabilities.
|
||||
|
||||
### Example Public Micro-Agent Implementation
|
||||
### Example Public Microagent Implementation
|
||||
|
||||
Here's a template for a new micro-agent:
|
||||
Here's a template for a new microagent:
|
||||
|
||||
```markdown
|
||||
---
|
||||
@@ -210,5 +149,5 @@ Remember to:
|
||||
- Optimize for build time and image size
|
||||
```
|
||||
|
||||
Remember that micro-agents are a powerful way to extend OpenHands' capabilities in specific domains. Well-designed
|
||||
agents can significantly improve the system's ability to handle specialized tasks.
|
||||
See the [current public micro-agents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for
|
||||
more examples.
|
||||
|
||||
@@ -1,25 +1,51 @@
|
||||
# Repository Micro-Agents
|
||||
# Repository Microagents
|
||||
|
||||
## Overview
|
||||
|
||||
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context
|
||||
and guidelines. This section explains how to optimize OpenHands for your project.
|
||||
|
||||
## Repository Configuration
|
||||
## Creating a Repository Micro-Agent
|
||||
|
||||
You can customize OpenHands' behavior for your repository by creating a `.openhands/microagents/` directory in your repository's root.
|
||||
At minimum, it should contain the file
|
||||
At minimum it should contain the file
|
||||
`.openhands/microagents/repo.md`, which includes instructions that will
|
||||
be given to the agent every time it works with this repository.
|
||||
|
||||
We suggest including the following information:
|
||||
### Repository Microagents Best Practices
|
||||
|
||||
- **Keep Instructions Updated**: Regularly update your `.openhands/microagents/` directory as your project evolves.
|
||||
- **Be Specific**: Include specific paths, patterns, and requirements unique to your project.
|
||||
- **Document Dependencies**: List all tools and dependencies required for development.
|
||||
- **Include Examples**: Provide examples of good code patterns from your project.
|
||||
- **Specify Conventions**: Document naming conventions, file organization, and code style preferences.
|
||||
|
||||
### Steps to Create a Repository Microagent
|
||||
|
||||
#### 1. Plan the Repository Microagent
|
||||
When creating a repository-specific micro-agent, we suggest including the following information:
|
||||
- **Repository Overview**: A brief description of your project's purpose and architecture.
|
||||
- **Directory Structure**: Key directories and their purposes.
|
||||
- **Development Guidelines**: Project-specific coding standards and practices.
|
||||
- **Testing Requirements**: How to run tests and what types of tests are required.
|
||||
- **Setup Instructions**: Steps needed to build and run the project.
|
||||
|
||||
### Example Repository Configuration
|
||||
Example `.openhands/microagents/repo.md` file:
|
||||
#### 2. Create File
|
||||
|
||||
Create a file in your repository under `.openhands/microagents/` (Example: `.openhands/microagents/repo.md`)
|
||||
|
||||
Update the file with the required frontmatter [according to the required format](./microagents-overview#microagent-format)
|
||||
and the required specialized guidelines for your repository.
|
||||
|
||||
### Example Repository Microagent
|
||||
|
||||
```
|
||||
---
|
||||
name: repo
|
||||
type: repo
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
Repository: MyProject
|
||||
Description: A web application for task management
|
||||
|
||||
@@ -37,30 +63,6 @@ Guidelines:
|
||||
- Follow ESLint configuration
|
||||
- Write tests for all new features
|
||||
- Use TypeScript for new code
|
||||
|
||||
If adding a new component in src/components, always add appropriate unit tests in tests/components/.
|
||||
```
|
||||
|
||||
### Customizing Prompts
|
||||
|
||||
You may also add customized prompts to the `.openhands/microagents/repo.md` file when working with a repository.
|
||||
These could:
|
||||
|
||||
- **Reference Project Standards**: Mention specific coding standards or patterns used in your project.
|
||||
- **Include Context**: Reference relevant documentation or existing implementations.
|
||||
- **Specify Testing Requirements**: Include project-specific testing requirements in your prompts.
|
||||
|
||||
Example customized prompt:
|
||||
```
|
||||
Add a new task completion feature to src/components/TaskList.tsx following our existing component patterns.
|
||||
Include unit tests in tests/components/ and update the documentation in docs/features/.
|
||||
The component should use our shared styling from src/styles/components.
|
||||
```
|
||||
|
||||
### Best Practices for Repository Customization
|
||||
|
||||
- **Keep Instructions Updated**: Regularly update your `.openhands/microagents/` directory as your project evolves.
|
||||
- **Be Specific**: Include specific paths, patterns, and requirements unique to your project.
|
||||
- **Document Dependencies**: List all tools and dependencies required for development.
|
||||
- **Include Examples**: Provide examples of good code patterns from your project.
|
||||
- **Specify Conventions**: Document naming conventions, file organization, and code style preferences.
|
||||
|
||||
By customizing OpenHands for your repository, you'll get more accurate and consistent results that align with your project's standards and requirements.
|
||||
|
||||
@@ -24,18 +24,23 @@ const sidebars: SidebarsConfig = {
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Micro-Agents',
|
||||
label: 'Microagents',
|
||||
items: [
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Public',
|
||||
id: 'usage/prompting/microagents-public',
|
||||
label: 'Overview',
|
||||
id: 'usage/prompting/microagents-overview',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Repository',
|
||||
id: 'usage/prompting/microagents-repo',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Public',
|
||||
id: 'usage/prompting/microagents-public',
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -132,11 +137,6 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Custom Sandbox',
|
||||
id: 'usage/how-to/custom-sandbox-guide',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Persist Session Data',
|
||||
id: 'usage/how-to/persist-session-data',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { retrieveLatestGitHubCommit } from "../../src/api/github";
|
||||
|
||||
describe("retrieveLatestGitHubCommit", () => {
|
||||
const { openHandsGetMock } = vi.hoisted(() => ({
|
||||
openHandsGetMock: vi.fn(),
|
||||
const { githubGetMock } = vi.hoisted(() => ({
|
||||
githubGetMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/api/open-hands-axios", () => ({
|
||||
openHands: {
|
||||
get: openHandsGetMock,
|
||||
vi.mock("../../src/api/github-axios-instance", () => ({
|
||||
github: {
|
||||
get: githubGetMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("retrieveLatestGitHubCommit", () => {
|
||||
},
|
||||
};
|
||||
|
||||
openHandsGetMock.mockResolvedValueOnce({
|
||||
githubGetMock.mockResolvedValueOnce({
|
||||
data: [mockCommit],
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("retrieveLatestGitHubCommit", () => {
|
||||
it("should return null when repository is empty", async () => {
|
||||
const error = new Error("Repository is empty");
|
||||
(error as any).response = { status: 409 };
|
||||
openHandsGetMock.mockRejectedValueOnce(error);
|
||||
githubGetMock.mockRejectedValueOnce(error);
|
||||
|
||||
const result = await retrieveLatestGitHubCommit("user/empty-repo");
|
||||
expect(result).toBeNull();
|
||||
@@ -40,7 +40,7 @@ describe("retrieveLatestGitHubCommit", () => {
|
||||
it("should throw error for other error cases", async () => {
|
||||
const error = new Error("Network error");
|
||||
(error as any).response = { status: 500 };
|
||||
openHandsGetMock.mockRejectedValueOnce(error);
|
||||
githubGetMock.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(retrieveLatestGitHubCommit("user/repo")).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("ConversationCard", () => {
|
||||
const onClick = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onChangeTitle = vi.fn();
|
||||
const onDownloadWorkspace = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -18,8 +19,8 @@ describe("ConversationCard", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -38,8 +39,8 @@ describe("ConversationCard", () => {
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -53,8 +54,8 @@ describe("ConversationCard", () => {
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -64,32 +65,13 @@ describe("ConversationCard", () => {
|
||||
screen.getByTestId("conversation-card-selected-repository");
|
||||
});
|
||||
|
||||
it("should call onClick when the card is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByTestId("conversation-card");
|
||||
await user.click(card);
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should toggle a context menu when clicking the ellipsis button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -112,8 +94,8 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -136,8 +118,8 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
@@ -157,8 +139,8 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -189,8 +171,8 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -213,8 +195,8 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -232,8 +214,8 @@ describe("ConversationCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -252,12 +234,126 @@ describe("ConversationCard", () => {
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onDownloadWorkspace when the download button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
onDownloadWorkspace={onDownloadWorkspace}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const downloadButton = within(menu).getByTestId("download-button");
|
||||
|
||||
await user.click(downloadButton);
|
||||
|
||||
expect(onDownloadWorkspace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not display the edit or delete options if the handler is not provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
expect(screen.queryByTestId("edit-button")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument();
|
||||
|
||||
// toggle to hide the context menu
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
expect(screen.queryByTestId("edit-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("delete-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the ellipsis button if there are no actions", () => {
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
onDownloadWorkspace={onDownloadWorkspace}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onDownloadWorkspace={onDownloadWorkspace}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDownloadWorkspace={onDownloadWorkspace}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("ellipsis-button")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("state indicator", () => {
|
||||
it("should render the 'STOPPED' indicator by default", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -271,8 +367,8 @@ describe("ConversationCard", () => {
|
||||
it("should render the other indicators when provided", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
QueryClientConfig,
|
||||
} from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
@@ -13,9 +14,15 @@ import { clickOnEditButton } from "./utils";
|
||||
|
||||
describe("ConversationPanel", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <ConversationPanel onClose={onCloseMock} />,
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
|
||||
const renderConversationPanel = (config?: QueryClientConfig) =>
|
||||
render(<ConversationPanel onClose={onCloseMock} />, {
|
||||
render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={new QueryClient(config)}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
|
||||
|
||||
const renderSidebar = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -43,4 +45,101 @@ describe("Sidebar", () => {
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
describe("Settings", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch settings data on mount", () => {
|
||||
renderSidebar();
|
||||
expect(getSettingsSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should send all settings data when saving AI configuration", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
"save-settings-button",
|
||||
);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
// the actual values are falsey (null or "") but we're checking for undefined
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: undefined,
|
||||
security_analyzer: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should send all settings data when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const menu = screen.getByTestId("account-settings-context-menu");
|
||||
const accountSettingsButton = within(menu).getByTestId(
|
||||
"account-settings-button",
|
||||
);
|
||||
await user.click(accountSettingsButton);
|
||||
|
||||
const accountSettingsModal = screen.getByTestId("account-settings-form");
|
||||
const saveButton =
|
||||
within(accountSettingsModal).getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
llm_api_key: undefined, // null or undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("should not reset AI configuration when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const menu = screen.getByTestId("account-settings-context-menu");
|
||||
const accountSettingsButton = within(menu).getByTestId(
|
||||
"account-settings-button",
|
||||
);
|
||||
await user.click(accountSettingsButton);
|
||||
|
||||
const accountSettingsModal = screen.getByTestId("account-settings-form");
|
||||
|
||||
const languageInput =
|
||||
within(accountSettingsModal).getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput =
|
||||
within(accountSettingsModal).getByLabelText(/github token/i);
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const saveButton =
|
||||
within(accountSettingsModal).getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
language: "no",
|
||||
llm_api_key: undefined, // null or undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
describe("SettingsForm", () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
@@ -19,10 +28,10 @@ describe("SettingsForm", () => {
|
||||
Component: () => (
|
||||
<SettingsForm
|
||||
settings={DEFAULT_SETTINGS}
|
||||
models={[]}
|
||||
agents={[]}
|
||||
securityAnalyzers={[]}
|
||||
onClose={() => {}}
|
||||
models={["anthropic/claude-3-5-sonnet-20241022", "model2"]}
|
||||
agents={["CodeActAgent", "agent2"]}
|
||||
securityAnalyzers={["analyzer1", "analyzer2"]}
|
||||
onClose={onCloseMock}
|
||||
/>
|
||||
),
|
||||
path: "/",
|
||||
@@ -35,11 +44,33 @@ describe("SettingsForm", () => {
|
||||
});
|
||||
|
||||
it("should show runtime size selector when advanced options are enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub />);
|
||||
const advancedSwitch = screen.getByRole("switch", {
|
||||
name: "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
|
||||
});
|
||||
fireEvent.click(advancedSwitch);
|
||||
await screen.findByText("SETTINGS_FORM$RUNTIME_SIZE_LABEL");
|
||||
|
||||
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
|
||||
await user.click(toggleAdvancedMode);
|
||||
|
||||
await screen.findByTestId("runtime-size");
|
||||
});
|
||||
|
||||
it("should not submit the form if required fields are empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub />);
|
||||
|
||||
expect(screen.queryByTestId("custom-model-input")).not.toBeInTheDocument();
|
||||
|
||||
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
|
||||
await user.click(toggleAdvancedMode);
|
||||
|
||||
const customModelInput = screen.getByTestId("custom-model-input");
|
||||
expect(customModelInput).toBeInTheDocument();
|
||||
|
||||
await user.clear(customModelInput);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,4 +27,17 @@ describe("Propagate error message", () => {
|
||||
type: 'error'
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error including translation id when present", () => {
|
||||
const message = "We have a problem!"
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage")
|
||||
updateStatusWhenErrorMessagePresent({message, data: {msg_id: '..id..'}})
|
||||
|
||||
expect(addErrorMessageSpy).toHaveBeenCalledWith({
|
||||
message,
|
||||
id: '..id..',
|
||||
status_update: true,
|
||||
type: 'error'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
230
frontend/package-lock.json
generated
230
frontend/package-lock.json
generated
@@ -24,7 +24,7 @@
|
||||
"i18next": "^24.2.1",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"isbot": "^5.1.20",
|
||||
"isbot": "^5.1.21",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.205.0",
|
||||
@@ -38,7 +38,7 @@
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.4",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
@@ -57,7 +57,7 @@
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.3",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
@@ -70,20 +70,20 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.3.0",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^1.6.0"
|
||||
@@ -124,6 +124,28 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.2.tgz",
|
||||
"integrity": "sha512-RtWv9jFN2/bLExuZgFFZ0I3pWWeezAHGgrmjqGGWclATl1aDe3yhCUaI0Ilkp6OCk9zX7+FjvDasEX8Q9Rxc5w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.1",
|
||||
"@csstools/css-color-parser": "^3.0.7",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz",
|
||||
"integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
@@ -691,6 +713,116 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.1.tgz",
|
||||
"integrity": "sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.1.tgz",
|
||||
"integrity": "sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.7.tgz",
|
||||
"integrity": "sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.0.1",
|
||||
"@csstools/css-calc": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz",
|
||||
"integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz",
|
||||
"integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -5447,9 +5579,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.3.tgz",
|
||||
"integrity": "sha512-UavfHguIjnnuq9O67uXfgy/h3SRJbidAYvNjLceB+2RIKVRBzVsh0QO+Pw6BCSQqFS9xwzKfwstXx0m6AbAREA==",
|
||||
"version": "19.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.4.tgz",
|
||||
"integrity": "sha512-3O4QisJDYr1uTUMZHA2YswiQZRq+Pd8D+GdVFYikTutYsTz+QZgWkAPnP7rx9txoI6EXKcPiluMqWPFV3tT9Wg==",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -7448,13 +7580,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz",
|
||||
"integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.2.1.tgz",
|
||||
"integrity": "sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rrweb-cssom": "^0.7.1"
|
||||
"@asamuzakjp/css-color": "^2.8.2",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -8316,13 +8448,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-prettier": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
|
||||
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz",
|
||||
"integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
"eslint-config-prettier": "build/bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=7.0.0"
|
||||
@@ -8565,11 +8696,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react": {
|
||||
"version": "7.37.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.3.tgz",
|
||||
"integrity": "sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==",
|
||||
"version": "7.37.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz",
|
||||
"integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-includes": "^3.1.8",
|
||||
"array.prototype.findlast": "^1.2.5",
|
||||
@@ -10670,9 +10800,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isbot": {
|
||||
"version": "5.1.20",
|
||||
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.20.tgz",
|
||||
"integrity": "sha512-cW535S5c05UBfx8bTAZHACjEXyY/p10bvAx5YeqoLEFoGC1HQ6A5n3ScpZRYd1zSwwNF8yYkEOq2F7WjFhX2ig==",
|
||||
"version": "5.1.21",
|
||||
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.21.tgz",
|
||||
"integrity": "sha512-0q3naRVpENL0ReKHeNcwn/G7BDynp0DqZUckKyFtM9+hmpnPqgm8+8wbjiVZ0XNhq1wPQV28/Pb8Snh5adeUHA==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -10808,23 +10938,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "25.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
|
||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||
"version": "26.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.0.0.tgz",
|
||||
"integrity": "sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.1.0",
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.1",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.12",
|
||||
"parse5": "^7.1.2",
|
||||
"rrweb-cssom": "^0.7.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.0.0",
|
||||
@@ -10832,7 +10961,7 @@
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0",
|
||||
"whatwg-url": "^14.1.0",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
@@ -10840,7 +10969,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^2.11.2"
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
@@ -14103,10 +14232,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.6.tgz",
|
||||
"integrity": "sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==",
|
||||
"license": "MIT",
|
||||
"version": "8.5.7",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.7.tgz",
|
||||
"integrity": "sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"use-composed-ref": "^1.3.0",
|
||||
@@ -14639,11 +14767,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
|
||||
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
@@ -16313,11 +16440,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
|
||||
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"i18next": "^24.2.1",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"isbot": "^5.1.20",
|
||||
"isbot": "^5.1.21",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.205.0",
|
||||
@@ -37,7 +37,7 @@
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.4",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.3",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
@@ -97,20 +97,20 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.3.0",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^1.6.0"
|
||||
|
||||
103
frontend/src/api/github-axios-instance.ts
Normal file
103
frontend/src/api/github-axios-instance.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
const github = axios.create({
|
||||
baseURL: "https://api.github.com",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
const setAuthTokenHeader = (token: string) => {
|
||||
github.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
};
|
||||
|
||||
const removeAuthTokenHeader = () => {
|
||||
if (github.defaults.headers.common.Authorization) {
|
||||
delete github.defaults.headers.common.Authorization;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if response has attributes to perform refresh
|
||||
*/
|
||||
const canRefresh = (error: unknown): boolean =>
|
||||
!!(
|
||||
error instanceof AxiosError &&
|
||||
error.config &&
|
||||
error.response &&
|
||||
error.response.status
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if the data is a GitHub error response
|
||||
* @param data The data to check
|
||||
* @returns Boolean indicating if the data is a GitHub error response
|
||||
*/
|
||||
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
|
||||
data: T | GitHubErrorReponse | null,
|
||||
): data is GitHubErrorReponse =>
|
||||
!!data && "message" in data && data.message !== undefined;
|
||||
|
||||
// Axios interceptor to handle token refresh
|
||||
const setupAxiosInterceptors = (
|
||||
refreshToken: () => Promise<boolean>,
|
||||
logout: () => void,
|
||||
) => {
|
||||
github.interceptors.response.use(
|
||||
// Pass successful responses through
|
||||
(response) => {
|
||||
const parsedData = response.data;
|
||||
if (isGitHubErrorReponse(parsedData)) {
|
||||
const error = new AxiosError(
|
||||
"Failed",
|
||||
"",
|
||||
response.config,
|
||||
response.request,
|
||||
response,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
// Retry request exactly once if token is expired
|
||||
async (error) => {
|
||||
if (!canRefresh(error)) {
|
||||
return Promise.reject(new Error("Failed to refresh token"));
|
||||
}
|
||||
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Check if the error is due to an expired token
|
||||
if (
|
||||
error.response.status === 401 &&
|
||||
!originalRequest._retry // Prevent infinite retry loops
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
return await github(originalRequest);
|
||||
}
|
||||
|
||||
logout();
|
||||
return await Promise.reject(new Error("Failed to refresh token"));
|
||||
} catch (refreshError) {
|
||||
// If token refresh fails, evict the user
|
||||
logout();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// If the error is not due to an expired token, propagate the error
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
github,
|
||||
setAuthTokenHeader,
|
||||
removeAuthTokenHeader,
|
||||
setupAxiosInterceptors,
|
||||
};
|
||||
@@ -1,18 +1,14 @@
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { github } from "./github-axios-instance";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
|
||||
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
|
||||
data: T | GitHubErrorReponse | null,
|
||||
): data is GitHubErrorReponse =>
|
||||
!!data && "message" in data && data.message !== undefined;
|
||||
|
||||
/**
|
||||
* Given the user, retrieves app installations IDs for OpenHands Github App
|
||||
* Uses user access token for Github App
|
||||
*/
|
||||
export const retrieveGitHubAppInstallations = async (): Promise<number[]> => {
|
||||
const response = await openHands.get<GithubAppInstallation>(
|
||||
"/api/github/installations",
|
||||
const response = await github.get<GithubAppInstallation>(
|
||||
"/user/installations",
|
||||
);
|
||||
|
||||
return response.data.installations.map((installation) => installation.id);
|
||||
@@ -92,8 +88,20 @@ export const retrieveGitHubUserRepositories = async (
|
||||
* @returns The authenticated user or an error response
|
||||
*/
|
||||
export const retrieveGitHubUser = async () => {
|
||||
const response = await openHands.get<GitHubUser>("/api/github/user");
|
||||
return response.data;
|
||||
const response = await github.get<GitHubUser>("/user");
|
||||
|
||||
const { data } = response;
|
||||
|
||||
const user: GitHubUser = {
|
||||
id: data.id,
|
||||
login: data.login,
|
||||
avatar_url: data.avatar_url,
|
||||
company: data.company,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const searchPublicRepositories = async (
|
||||
@@ -102,11 +110,11 @@ export const searchPublicRepositories = async (
|
||||
sort: "" | "updated" | "stars" | "forks" = "stars",
|
||||
order: "desc" | "asc" = "desc",
|
||||
): Promise<GitHubRepository[]> => {
|
||||
const response = await openHands.get<{ items: GitHubRepository[] }>(
|
||||
"/api/github/search/repositories",
|
||||
const response = await github.get<{ items: GitHubRepository[] }>(
|
||||
"/search/repositories",
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
q: query,
|
||||
per_page,
|
||||
sort,
|
||||
order,
|
||||
@@ -120,9 +128,8 @@ export const retrieveLatestGitHubCommit = async (
|
||||
repository: string,
|
||||
): Promise<GitHubCommit | null> => {
|
||||
try {
|
||||
const [owner, repo] = repository.split("/");
|
||||
const response = await openHands.get<GitHubCommit[]>(
|
||||
`/api/github/repos/${owner}/${repo}/commits`,
|
||||
const response = await github.get<GitHubCommit[]>(
|
||||
`/repos/${repository}/commits`,
|
||||
{
|
||||
params: {
|
||||
per_page: 1,
|
||||
|
||||
@@ -27,7 +27,10 @@ export function AccountSettingsContextMenu({
|
||||
ref={ref}
|
||||
className="absolute left-full -top-1 z-10"
|
||||
>
|
||||
<ContextMenuListItem onClick={onClickAccountSettings}>
|
||||
<ContextMenuListItem
|
||||
testId="account-settings-button"
|
||||
onClick={onClickAccountSettings}
|
||||
>
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
import { useParams } from "react-router";
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { AgentControlBar } from "./agent-control-bar";
|
||||
import { AgentStatusBar } from "./agent-status-bar";
|
||||
import { ProjectMenuCard } from "../project-menu/ProjectMenuCard";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { RootState } from "#/store";
|
||||
import { SecurityLock } from "./security-lock";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
import { ConversationCard } from "../conversation-panel/conversation-card";
|
||||
import { DownloadModal } from "#/components/shared/download-modal";
|
||||
|
||||
interface ControlsProps {
|
||||
setSecurityOpen: (isOpen: boolean) => void;
|
||||
showSecurityLock: boolean;
|
||||
lastCommitData: GitHubCommit | null;
|
||||
}
|
||||
|
||||
export function Controls({
|
||||
setSecurityOpen,
|
||||
showSecurityLock,
|
||||
lastCommitData,
|
||||
}: ControlsProps) {
|
||||
const { gitHubToken } = useAuth();
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
|
||||
const params = useParams();
|
||||
const { data: conversation } = useUserConversation(
|
||||
params.conversationId ?? null,
|
||||
);
|
||||
|
||||
const projectMenuCardData = React.useMemo(
|
||||
() =>
|
||||
selectedRepository && lastCommitData
|
||||
? {
|
||||
repoName: selectedRepository,
|
||||
lastCommit: lastCommitData,
|
||||
avatar: null, // TODO: fetch repo avatar
|
||||
}
|
||||
: null,
|
||||
[selectedRepository, lastCommitData],
|
||||
);
|
||||
const [downloading, setDownloading] = React.useState(false);
|
||||
|
||||
const handleDownloadWorkspace = () => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
setDownloading(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -46,9 +37,19 @@ export function Controls({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProjectMenuCard
|
||||
isConnectedToGitHub={!!gitHubToken}
|
||||
githubData={projectMenuCardData}
|
||||
<ConversationCard
|
||||
variant="compact"
|
||||
onDownloadWorkspace={handleDownloadWorkspace}
|
||||
title={conversation?.title ?? ""}
|
||||
lastUpdatedAt={conversation?.created_at ?? ""}
|
||||
selectedRepository={conversation?.selected_repository ?? null}
|
||||
status={conversation?.status}
|
||||
/>
|
||||
|
||||
<DownloadModal
|
||||
initialPath=""
|
||||
onClose={() => setDownloading(false)}
|
||||
isOpen={downloading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
|
||||
interface ConversationCardContextMenuProps {
|
||||
onClose: () => void;
|
||||
onDelete: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onEdit: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
position?: "top" | "bottom";
|
||||
}
|
||||
|
||||
export function ConversationCardContextMenu({
|
||||
onClose,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDownload,
|
||||
position = "bottom",
|
||||
}: ConversationCardContextMenuProps) {
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
@@ -19,14 +24,27 @@ export function ConversationCardContextMenu({
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
testId="context-menu"
|
||||
className="left-full float-right"
|
||||
className={cn(
|
||||
"right-0 absolute",
|
||||
position === "top" && "bottom-full",
|
||||
position === "bottom" && "top-full",
|
||||
)}
|
||||
>
|
||||
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
|
||||
Edit Title
|
||||
</ContextMenuListItem>
|
||||
{onDelete && (
|
||||
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onEdit && (
|
||||
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
|
||||
Edit Title
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onDownload && (
|
||||
<ContextMenuListItem testId="download-button" onClick={onDownload}>
|
||||
Download Workspace
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,25 +7,32 @@ import {
|
||||
} from "./conversation-state-indicator";
|
||||
import { EllipsisButton } from "./ellipsis-button";
|
||||
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ConversationCardProps {
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
onChangeTitle: (title: string) => void;
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
onChangeTitle?: (title: string) => void;
|
||||
onDownloadWorkspace?: () => void;
|
||||
isActive?: boolean;
|
||||
title: string;
|
||||
selectedRepository: string | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
status?: ProjectStatus;
|
||||
variant?: "compact" | "default";
|
||||
}
|
||||
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
onChangeTitle,
|
||||
onDownloadWorkspace,
|
||||
isActive,
|
||||
title,
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
status = "STOPPED",
|
||||
variant = "default",
|
||||
}: ConversationCardProps) {
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||
@@ -34,7 +41,7 @@ export function ConversationCard({
|
||||
const handleBlur = () => {
|
||||
if (inputRef.current?.value) {
|
||||
const trimmed = inputRef.current.value.trim();
|
||||
onChangeTitle(trimmed);
|
||||
onChangeTitle?.(trimmed);
|
||||
inputRef.current!.value = trimmed;
|
||||
} else {
|
||||
// reset the value if it's empty
|
||||
@@ -51,71 +58,100 @@ export function ConversationCard({
|
||||
};
|
||||
|
||||
const handleInputClick = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDelete = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
onDelete?.();
|
||||
};
|
||||
|
||||
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTitleMode("edit");
|
||||
setContextMenuVisible(false);
|
||||
};
|
||||
|
||||
const handleDownload = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onDownloadWorkspace?.();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (titleMode === "edit") {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [titleMode]);
|
||||
|
||||
const hasContextMenu = !!(onDelete || onChangeTitle || onDownloadWorkspace);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
onClick={onClick}
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer"
|
||||
className={cn(
|
||||
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
|
||||
variant === "compact" &&
|
||||
"h-auto w-fit rounded-xl border border-[#525252]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-1">
|
||||
<input
|
||||
data-testid="conversation-card-title"
|
||||
ref={inputRef}
|
||||
disabled={titleMode === "view"}
|
||||
onClick={handleInputClick}
|
||||
onBlur={handleBlur}
|
||||
onKeyUp={handleKeyUp}
|
||||
type="text"
|
||||
defaultValue={title}
|
||||
className="text-sm leading-6 font-semibold bg-transparent w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{isActive && <span className="w-2 h-2 bg-blue-500 rounded-full" />}
|
||||
<input
|
||||
ref={inputRef}
|
||||
disabled={titleMode === "view"}
|
||||
data-testid="conversation-card-title"
|
||||
onClick={handleInputClick}
|
||||
onBlur={handleBlur}
|
||||
onKeyUp={handleKeyUp}
|
||||
type="text"
|
||||
defaultValue={title}
|
||||
className="text-sm leading-6 font-semibold bg-transparent w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<ConversationStateIndicator status={status} />
|
||||
<EllipsisButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
{hasContextMenu && (
|
||||
<EllipsisButton
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{contextMenuVisible && (
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
onDelete={onDelete && handleDelete}
|
||||
onEdit={onChangeTitle && handleEdit}
|
||||
onDownload={onDownloadWorkspace && handleDownload}
|
||||
position={variant === "compact" ? "top" : "bottom"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{contextMenuVisible && (
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
)}
|
||||
{selectedRepository && (
|
||||
<ConversationRepoLink
|
||||
selectedRepository={selectedRepository}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
variant === "compact" && "flex items-center justify-between mt-1",
|
||||
)}
|
||||
>
|
||||
{selectedRepository && (
|
||||
<ConversationRepoLink
|
||||
selectedRepository={selectedRepository}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { NavLink, useParams } from "react-router";
|
||||
import { ConversationCard } from "./conversation-card";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
@@ -16,7 +16,6 @@ interface ConversationPanelProps {
|
||||
|
||||
export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const { conversationId: cid } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const endSession = useEndSession();
|
||||
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
|
||||
|
||||
@@ -63,11 +62,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickCard = (conversationId: string) => {
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -88,18 +82,25 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
{conversations?.map((project) => (
|
||||
<ConversationCard
|
||||
<NavLink
|
||||
key={project.conversation_id}
|
||||
onClick={() => handleClickCard(project.conversation_id)}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.title, title)
|
||||
}
|
||||
title={project.title}
|
||||
selectedRepository={project.selected_repository}
|
||||
lastUpdatedAt={project.last_updated_at}
|
||||
status={project.status}
|
||||
/>
|
||||
to={`/conversations/${project.conversation_id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<ConversationCard
|
||||
isActive={isActive}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.title, title)
|
||||
}
|
||||
title={project.title}
|
||||
selectedRepository={project.selected_repository}
|
||||
lastUpdatedAt={project.last_updated_at}
|
||||
status={project.status}
|
||||
/>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{confirmDeleteModalVisible && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GitHubRepositorySelector } from "./github-repo-selector";
|
||||
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { isGitHubErrorReponse } from "#/api/github";
|
||||
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
|
||||
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EllipsisH from "#/icons/ellipsis-h.svg?react";
|
||||
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
|
||||
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
|
||||
import { ProjectMenuDetails } from "./project-menu-details";
|
||||
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { DownloadModal } from "#/components/shared/download-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuCardProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
githubData: {
|
||||
avatar: string | null;
|
||||
repoName: string;
|
||||
lastCommit: GitHubCommit;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function ProjectMenuCard({
|
||||
isConnectedToGitHub,
|
||||
githubData,
|
||||
}: ProjectMenuCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [downloading, setDownloading] = React.useState(false);
|
||||
|
||||
const toggleMenuVisibility = () => {
|
||||
setContextMenuIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleDownloadWorkspace = () => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
setDownloading(true);
|
||||
};
|
||||
|
||||
const handleDownloadClose = () => {
|
||||
setDownloading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
|
||||
{!downloading && contextMenuIsOpen && (
|
||||
<ProjectMenuCardContextMenu
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
onDownloadWorkspace={handleDownloadWorkspace}
|
||||
onClose={() => setContextMenuIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{githubData && (
|
||||
<ProjectMenuDetails
|
||||
repoName={githubData.repoName}
|
||||
avatar={githubData.avatar}
|
||||
lastCommit={githubData.lastCommit}
|
||||
/>
|
||||
)}
|
||||
{!githubData && (
|
||||
<ProjectMenuDetailsPlaceholder
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<DownloadModal
|
||||
initialPath=""
|
||||
onClose={handleDownloadClose}
|
||||
isOpen={downloading}
|
||||
/>
|
||||
{!downloading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMenuVisibility}
|
||||
aria-label={t(I18nKey.PROJECT_MENU_CARD$OPEN)}
|
||||
>
|
||||
<EllipsisH width={36} height={36} />
|
||||
</button>
|
||||
)}
|
||||
{connectToGitHubModalOpen && (
|
||||
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
|
||||
<ConnectToGitHubModal
|
||||
onClose={() => setConnectToGitHubModalOpen(false)}
|
||||
/>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import CloudConnection from "#/icons/cloud-connection.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsPlaceholderProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
onConnectToGitHub: () => void;
|
||||
}
|
||||
|
||||
export function ProjectMenuDetailsPlaceholder({
|
||||
isConnectedToGitHub,
|
||||
onConnectToGitHub,
|
||||
}: ProjectMenuDetailsPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm leading-6 font-semibold">
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnectToGitHub}
|
||||
disabled={isConnectedToGitHub}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs leading-4 text-[#A3A3A3] flex items-center gap-2",
|
||||
"hover:underline hover:underline-offset-2",
|
||||
)}
|
||||
>
|
||||
{!isConnectedToGitHub
|
||||
? t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB)
|
||||
: t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED)}
|
||||
<CloudConnection width={12} height={12} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ExternalLinkIcon from "#/icons/external-link.svg?react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsProps {
|
||||
repoName: string;
|
||||
avatar: string | null;
|
||||
lastCommit: GitHubCommit;
|
||||
}
|
||||
|
||||
export function ProjectMenuDetails({
|
||||
repoName,
|
||||
avatar,
|
||||
lastCommit,
|
||||
}: ProjectMenuDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<a
|
||||
href={`https://github.com/${repoName}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center gap-2 min-w-0"
|
||||
>
|
||||
{avatar && (
|
||||
<img
|
||||
src={avatar}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded-full flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm leading-6 font-semibold truncate flex-1">
|
||||
{repoName}
|
||||
</span>
|
||||
<ExternalLinkIcon width={16} height={16} className="flex-shrink-0" />
|
||||
</a>
|
||||
<a
|
||||
href={lastCommit.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-xs text-[#A3A3A3] hover:underline hover:underline-offset-2"
|
||||
>
|
||||
<span>{lastCommit.sha.slice(-7)}</span> <span>·</span>{" "}
|
||||
<span>
|
||||
{formatTimeDelta(new Date(lastCommit.commit.author.date))}{" "}
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS$AGO_LABEL)}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuCardContextMenuProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
onConnectToGitHub: () => void;
|
||||
onDownloadWorkspace: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProjectMenuCardContextMenu({
|
||||
isConnectedToGitHub,
|
||||
onConnectToGitHub,
|
||||
onDownloadWorkspace,
|
||||
onClose,
|
||||
}: ProjectMenuCardContextMenuProps) {
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
className="absolute right-0 bottom-[calc(100%+8px)]"
|
||||
>
|
||||
{!isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onConnectToGitHub}>
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
<ContextMenuListItem onClick={onDownloadWorkspace}>
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/features/served-host/path-form.tsx
Normal file
19
frontend/src/components/features/served-host/path-form.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface PathFormProps {
|
||||
ref: React.RefObject<HTMLFormElement | null>;
|
||||
onBlur: () => void;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
export function PathForm({ ref, onBlur, defaultValue }: PathFormProps) {
|
||||
return (
|
||||
<form ref={ref} onSubmit={(e) => e.preventDefault()} className="flex-1">
|
||||
<input
|
||||
name="url"
|
||||
type="text"
|
||||
defaultValue={defaultValue}
|
||||
className="w-full bg-transparent"
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { SettingsButton } from "#/components/shared/buttons/settings-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
@@ -28,8 +28,13 @@ export function Sidebar() {
|
||||
const user = useGitHubUser();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { logout } = useAuth();
|
||||
const { data: settings, isError: settingsIsError } = useSettings();
|
||||
const { isUpToDate: settingsAreUpToDate } = useSettingsUpToDate();
|
||||
const {
|
||||
data: settings,
|
||||
isError: settingsIsError,
|
||||
isSuccess: settingsSuccessfulyFetched,
|
||||
} = useSettings();
|
||||
|
||||
const { isUpToDate: settingsAreUpToDate } = useCurrentSettings();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
@@ -72,7 +77,7 @@ export function Sidebar() {
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
{MULTI_CONVERSATION_UI && (
|
||||
<TooltipButton
|
||||
data-testid="toggle-conversation-panel"
|
||||
testId="toggle-conversation-panel"
|
||||
tooltip="Conversations"
|
||||
ariaLabel="Conversations"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
@@ -106,7 +111,7 @@ export function Sidebar() {
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{settingsIsError ||
|
||||
(showSettingsModal && (
|
||||
(showSettingsModal && settingsSuccessfulyFetched && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
|
||||
15
frontend/src/components/layout/served-app-label.tsx
Normal file
15
frontend/src/components/layout/served-app-label.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useActiveHost } from "#/hooks/query/use-active-host";
|
||||
|
||||
export function ServedAppLabel() {
|
||||
const { activeHost } = useActiveHost();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">App</div>
|
||||
<span className="border rounded-md text- px-1 font-bold">BETA</span>
|
||||
</div>
|
||||
{activeHost && <div className="w-2 h-2 bg-green-500 rounded-full" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export function AdvancedOptionSwitch({
|
||||
|
||||
return (
|
||||
<Switch
|
||||
data-testid="advanced-option-switch"
|
||||
isDisabled={isDisabled}
|
||||
name="use-advanced-options"
|
||||
defaultSelected={showAdvancedOptions}
|
||||
|
||||
@@ -22,7 +22,9 @@ export function CustomModelInput({
|
||||
{t(I18nKey.SETTINGS_FORM$CUSTOM_MODEL_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
data-testid="custom-model-input"
|
||||
isDisabled={isDisabled}
|
||||
isRequired
|
||||
id="custom-model"
|
||||
name="custom-model"
|
||||
defaultValue={defaultValue}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ModalButton } from "../../buttons/modal-button";
|
||||
import { CustomInput } from "../../custom-input";
|
||||
import { FormFieldset } from "../../form-fieldset";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
|
||||
interface AccountSettingsFormProps {
|
||||
onClose: () => void;
|
||||
@@ -30,10 +30,10 @@ export function AccountSettingsForm({
|
||||
}: AccountSettingsFormProps) {
|
||||
const { gitHubToken, setGitHubToken, logout } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { mutate: saveSettings } = useSaveSettings();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function AccountSettingsForm({
|
||||
({ label }) => label === language,
|
||||
)?.value;
|
||||
|
||||
if (languageKey) saveSettings({ LANGUAGE: languageKey });
|
||||
if (languageKey) await saveUserSettings({ LANGUAGE: languageKey });
|
||||
}
|
||||
|
||||
handleCaptureConsent(analytics);
|
||||
@@ -61,7 +61,7 @@ export function AccountSettingsForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
<ModalBody testID="account-settings-form">
|
||||
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<BaseModalTitle title="Account Settings" />
|
||||
@@ -137,6 +137,7 @@ export function AccountSettingsForm({
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
testId="save-settings"
|
||||
type="submit"
|
||||
intent="account"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<AccountSettingsForm
|
||||
onClose={onClose}
|
||||
selectedLanguage={settings.LANGUAGE}
|
||||
selectedLanguage={settings?.LANGUAGE || "en"}
|
||||
gitHubError={user.isError}
|
||||
analyticsConsent={analyticsConsent}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ export function RuntimeSizeSelector({
|
||||
{t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
|
||||
</label>
|
||||
<Select
|
||||
data-testid="runtime-size"
|
||||
id="runtime-size"
|
||||
name="runtime-size"
|
||||
defaultSelectedKeys={[String(defaultValue || 1)]}
|
||||
|
||||
@@ -19,10 +19,10 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
|
||||
import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
import { RuntimeSizeSelector } from "./runtime-size-selector";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@@ -41,7 +41,7 @@ export function SettingsForm({
|
||||
securityAnalyzers,
|
||||
onClose,
|
||||
}: SettingsFormProps) {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const endSession = useEndSession();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
@@ -95,7 +95,8 @@ export function SettingsForm({
|
||||
const newSettings = extractSettings(formData);
|
||||
|
||||
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
|
||||
await saveSettings(newSettings, { onSuccess: onClose });
|
||||
await saveUserSettings(newSettings);
|
||||
onClose();
|
||||
resetOngoingSession();
|
||||
|
||||
posthog.capture("settings_saved", {
|
||||
@@ -107,7 +108,8 @@ export function SettingsForm({
|
||||
};
|
||||
|
||||
const handleConfirmResetSettings = async () => {
|
||||
await saveSettings(getDefaultSettings(), { onSuccess: onClose });
|
||||
await saveUserSettings(getDefaultSettings());
|
||||
onClose();
|
||||
resetOngoingSession();
|
||||
posthog.capture("settings_reset");
|
||||
};
|
||||
@@ -204,6 +206,7 @@ export function SettingsForm({
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<ModalButton
|
||||
testId="save-settings-button"
|
||||
disabled={disabled}
|
||||
type="submit"
|
||||
text={t(I18nKey.SETTINGS_FORM$SAVE_LABEL)}
|
||||
|
||||
@@ -2,9 +2,14 @@ import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
removeGitHubTokenHeader,
|
||||
setGitHubTokenHeader,
|
||||
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
|
||||
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
|
||||
} from "#/api/open-hands-axios";
|
||||
import {
|
||||
setAuthTokenHeader as setGitHubAuthTokenHeader,
|
||||
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
|
||||
setupAxiosInterceptors as setupGithubAxiosInterceptors,
|
||||
} from "#/api/github-axios-instance";
|
||||
|
||||
interface AuthContextType {
|
||||
gitHubToken: string | null;
|
||||
@@ -32,7 +37,8 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
localStorage.removeItem("ghToken");
|
||||
localStorage.removeItem("userId");
|
||||
|
||||
removeGitHubTokenHeader();
|
||||
removeOpenHandsGitHubTokenHeader();
|
||||
removeGitHubAuthTokenHeader();
|
||||
};
|
||||
|
||||
const setGitHubToken = (token: string | null) => {
|
||||
@@ -40,7 +46,8 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("ghToken", token);
|
||||
setGitHubTokenHeader(token);
|
||||
setOpenHandsGitHubTokenHeader(token);
|
||||
setGitHubAuthTokenHeader(token);
|
||||
} else {
|
||||
clearGitHubToken();
|
||||
}
|
||||
@@ -80,6 +87,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
|
||||
setGitHubToken(storedGitHubToken);
|
||||
setUserId(userId);
|
||||
setupGithubAxiosInterceptors(refreshToken, logout);
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
|
||||
70
frontend/src/context/settings-context.tsx
Normal file
70
frontend/src/context/settings-context.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import {
|
||||
LATEST_SETTINGS_VERSION,
|
||||
Settings,
|
||||
settingsAreUpToDate,
|
||||
} from "#/services/settings";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
interface SettingsContextType {
|
||||
isUpToDate: boolean;
|
||||
setIsUpToDate: (value: boolean) => void;
|
||||
saveUserSettings: (newSettings: Partial<Settings>) => Promise<void>;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
const SettingsContext = React.createContext<SettingsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface SettingsProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
const { data: userSettings } = useSettings();
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
|
||||
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
|
||||
|
||||
const saveUserSettings = async (newSettings: Partial<Settings>) => {
|
||||
const updatedSettings: Partial<Settings> = {
|
||||
...userSettings,
|
||||
...newSettings,
|
||||
};
|
||||
await saveSettings(updatedSettings, {
|
||||
onSuccess: () => {
|
||||
if (!isUpToDate) {
|
||||
localStorage.setItem(
|
||||
"SETTINGS_VERSION",
|
||||
LATEST_SETTINGS_VERSION.toString(),
|
||||
);
|
||||
setIsUpToDate(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
isUpToDate,
|
||||
setIsUpToDate,
|
||||
saveUserSettings,
|
||||
settings: userSettings,
|
||||
}),
|
||||
[isUpToDate, setIsUpToDate, saveUserSettings, userSettings],
|
||||
);
|
||||
|
||||
return <SettingsContext value={value}>{children}</SettingsContext>;
|
||||
}
|
||||
|
||||
export function useCurrentSettings() {
|
||||
const context = React.useContext(SettingsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useCurrentSettings must be used within a SettingsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from "react";
|
||||
import { settingsAreUpToDate } from "#/services/settings";
|
||||
|
||||
interface SettingsUpToDateContextType {
|
||||
isUpToDate: boolean;
|
||||
setIsUpToDate: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const SettingsUpToDateContext = React.createContext<
|
||||
SettingsUpToDateContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
interface SettingsUpToDateProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsUpToDateProvider({
|
||||
children,
|
||||
}: SettingsUpToDateProviderProps) {
|
||||
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ isUpToDate, setIsUpToDate }),
|
||||
[isUpToDate, setIsUpToDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsUpToDateContext value={value}>{children}</SettingsUpToDateContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSettingsUpToDate() {
|
||||
const context = React.useContext(SettingsUpToDateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useSettingsUpToDate must be used within a SettingsUpToDateProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -67,16 +67,33 @@ interface WsClientProviderProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function updateStatusWhenErrorMessagePresent(data: unknown) {
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"message" in data &&
|
||||
typeof data.message === "string"
|
||||
) {
|
||||
interface ErrorArg {
|
||||
message?: string;
|
||||
data?: ErrorArgData | unknown;
|
||||
}
|
||||
|
||||
interface ErrorArgData {
|
||||
msg_id: string;
|
||||
}
|
||||
|
||||
export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
|
||||
const isObject = (val: unknown): val is object =>
|
||||
!!val && typeof val === "object";
|
||||
const isString = (val: unknown): val is string => typeof val === "string";
|
||||
if (isObject(data) && "message" in data && isString(data.message)) {
|
||||
let msgId: string | undefined;
|
||||
if (
|
||||
"data" in data &&
|
||||
isObject(data.data) &&
|
||||
"msg_id" in data.data &&
|
||||
isString(data.data.msg_id)
|
||||
) {
|
||||
msgId = data.data.msg_id;
|
||||
}
|
||||
handleStatusMessage({
|
||||
type: "error",
|
||||
message: data.message,
|
||||
id: msgId,
|
||||
status_update: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import toast from "react-hot-toast";
|
||||
import store from "./store";
|
||||
import { useConfig } from "./hooks/query/use-config";
|
||||
import { AuthProvider } from "./context/auth-context";
|
||||
import { SettingsUpToDateProvider } from "./context/settings-up-to-date-context";
|
||||
import { SettingsProvider } from "./context/settings-context";
|
||||
|
||||
function PosthogInit() {
|
||||
const { data: config } = useConfig();
|
||||
@@ -50,13 +50,20 @@ async function prepareApp() {
|
||||
}
|
||||
}
|
||||
|
||||
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error, query) => {
|
||||
if (!query.queryKey.includes("authenticated")) toast.error(error.message);
|
||||
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
},
|
||||
mutations: {
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
@@ -72,12 +79,12 @@ prepareApp().then(() =>
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<SettingsUpToDateProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SettingsProvider>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
</QueryClientProvider>
|
||||
</SettingsUpToDateProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
LATEST_SETTINGS_VERSION,
|
||||
Settings,
|
||||
} from "#/services/settings";
|
||||
import { ApiSettings, DEFAULT_SETTINGS, Settings } from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
|
||||
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
|
||||
const apiSettings: Partial<ApiSettings> = {
|
||||
@@ -24,19 +18,11 @@ const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
|
||||
|
||||
export const useSaveSettings = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { isUpToDate, setIsUpToDate } = useSettingsUpToDate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: saveSettingsMutationFn,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
if (!isUpToDate) {
|
||||
localStorage.setItem(
|
||||
"SETTINGS_VERSION",
|
||||
LATEST_SETTINGS_VERSION.toString(),
|
||||
);
|
||||
setIsUpToDate(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
51
frontend/src/hooks/query/use-active-host.ts
Normal file
51
frontend/src/hooks/query/use-active-host.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { RootState } from "#/store";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
export const useActiveHost = () => {
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const [activeHost, setActiveHost] = React.useState<string | null>(null);
|
||||
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: [conversationId, "hosts"],
|
||||
queryFn: async () => {
|
||||
const response = await openHands.get<{ hosts: string[] }>(
|
||||
`/api/conversations/${conversationId}/web-hosts`,
|
||||
);
|
||||
return { hosts: Object.keys(response.data.hosts) };
|
||||
},
|
||||
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
|
||||
initialData: { hosts: [] },
|
||||
});
|
||||
|
||||
const apps = useQueries({
|
||||
queries: data.hosts.map((host) => ({
|
||||
queryKey: [conversationId, "hosts", host],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
await axios.get(host);
|
||||
return host;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
})),
|
||||
});
|
||||
|
||||
const appsData = apps.map((app) => app.data);
|
||||
|
||||
React.useEffect(() => {
|
||||
const successfulApp = appsData.find((app) => app);
|
||||
setActiveHost(successfulApp || "");
|
||||
}, [appsData]);
|
||||
|
||||
return { activeHost };
|
||||
};
|
||||
@@ -39,7 +39,6 @@ export const useSettings = () => {
|
||||
const query = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettingsQueryFn,
|
||||
initialData: DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
export const useUserConversation = (cid: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["user", "conversation", cid],
|
||||
queryFn: () => OpenHands.getConversation(cid!),
|
||||
enabled: MULTI_CONVERSATION_UI && !!cid,
|
||||
enabled: !!cid,
|
||||
retry: false,
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// Sometimes we ship major changes, like a new default agent.
|
||||
|
||||
import React from "react";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import {
|
||||
getCurrentSettingsVersion,
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -12,7 +12,7 @@ import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
// In this case, we may want to override a previous choice made by the user.
|
||||
export const useMaybeMigrateSettings = () => {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const { isUpToDate } = useSettingsUpToDate();
|
||||
const { isUpToDate } = useCurrentSettings();
|
||||
|
||||
const maybeMigrateSettings = async () => {
|
||||
const currentVersion = getCurrentSettingsVersion();
|
||||
|
||||
@@ -131,7 +131,9 @@ export const useTerminal = ({
|
||||
content = content.replaceAll(secret, "*".repeat(10));
|
||||
});
|
||||
|
||||
terminal.current?.writeln(parseTerminalOutput(content));
|
||||
terminal.current?.writeln(
|
||||
parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()),
|
||||
);
|
||||
|
||||
if (type === "output") {
|
||||
terminal.current.write(`\n$ `);
|
||||
|
||||
@@ -454,6 +454,10 @@
|
||||
"fr": "Nous avons modifié certains paramètres dans la dernière mise à jour. Prenez un moment pour les examiner.",
|
||||
"tr": "Son güncellemede bazı ayarları değiştirdik. Gözden geçirmek için bir dakikanızı ayırın."
|
||||
},
|
||||
"CONFIGURATION$SETTINGS_NOT_FOUND": {
|
||||
"en": "Settings not found. Please check your API key",
|
||||
"es": "Configuraciones no encontradas. Por favor revisa tu API key"
|
||||
},
|
||||
"CONFIGURATION$AGENT_LOADING": {
|
||||
"en": "Please wait while the agent loads. This may take a few minutes...",
|
||||
"de": "Bitte warten Sie, während der Agent lädt. Das kann ein paar Minuten dauern...",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "#/api/open-hands.types";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
const userPreferences = {
|
||||
export const MOCK_USER_PREFERENCES = {
|
||||
settings: {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
|
||||
@@ -169,14 +169,14 @@ export const handlers = [
|
||||
return HttpResponse.json(config);
|
||||
}),
|
||||
http.get("/api/settings", async () =>
|
||||
HttpResponse.json(userPreferences.settings),
|
||||
HttpResponse.json(MOCK_USER_PREFERENCES.settings),
|
||||
),
|
||||
http.post("/api/settings", async ({ request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (body) {
|
||||
userPreferences.settings = {
|
||||
...userPreferences.settings,
|
||||
MOCK_USER_PREFERENCES.settings = {
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
// @ts-expect-error - We know this is a settings object
|
||||
...body,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export default [
|
||||
index("routes/_oh.app._index/route.tsx"),
|
||||
route("browser", "routes/_oh.app.browser.tsx"),
|
||||
route("jupyter", "routes/_oh.app.jupyter.tsx"),
|
||||
route("served", "routes/app.tsx"),
|
||||
]),
|
||||
]),
|
||||
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { getGitHubTokenCommand } from "#/services/terminal-service";
|
||||
import { setImportedProjectZip } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { base64ToBlob } from "#/utils/base64-to-blob";
|
||||
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
|
||||
import { useGitHubUser } from "../../../hooks/query/use-github-user";
|
||||
import { isGitHubErrorReponse } from "#/api/github";
|
||||
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
|
||||
export const useHandleRuntimeActive = () => {
|
||||
const { gitHubToken } = useAuth();
|
||||
const { send } = useWsClient();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: user } = useGitHubUser();
|
||||
const { mutate: uploadFiles } = useUploadFiles();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
@@ -28,11 +20,6 @@ export const useHandleRuntimeActive = () => {
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
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", {
|
||||
@@ -49,13 +36,6 @@ export const useHandleRuntimeActive = () => {
|
||||
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);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useDisclosure } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { FaServer } from "react-icons/fa";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
ConversationProvider,
|
||||
@@ -20,7 +21,6 @@ import { FilesProvider } from "#/context/files";
|
||||
import { ChatInterface } from "../../components/features/chat/chat-interface";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { EventHandler } from "./event-handler";
|
||||
import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
||||
import { Container } from "#/components/layout/container";
|
||||
@@ -30,38 +30,27 @@ import {
|
||||
} from "#/components/layout/resizable-panel";
|
||||
import Security from "#/components/shared/modals/security/security";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { useUserConversation } from "#/hooks/query/get-conversation-permissions";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
import { CountBadge } from "#/components/layout/count-badge";
|
||||
import { ServedAppLabel } from "#/components/layout/served-app-label";
|
||||
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const endSession = useEndSession();
|
||||
const [width, setWidth] = React.useState(window.innerWidth);
|
||||
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useConversationConfig();
|
||||
const { data: conversation, isFetched } = useUserConversation(
|
||||
conversationId || null,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
const [width, setWidth] = React.useState(window.innerWidth);
|
||||
const { updateCount } = useSelector((state: RootState) => state.browser);
|
||||
|
||||
const { data: latestGitHubCommit } = useLatestRepoCommit({
|
||||
repository: selectedRepository,
|
||||
});
|
||||
|
||||
const secrets = React.useMemo(
|
||||
() => [gitHubToken].filter((secret) => secret !== null),
|
||||
[gitHubToken],
|
||||
@@ -139,6 +128,11 @@ function AppContent() {
|
||||
labels={[
|
||||
{ label: "Workspace", to: "", icon: <CodeIcon /> },
|
||||
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
|
||||
{
|
||||
label: <ServedAppLabel />,
|
||||
to: "served",
|
||||
icon: <FaServer />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -180,18 +174,17 @@ function AppContent() {
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
|
||||
<div className="h-[60px]">
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings.SECURITY_ANALYZER}
|
||||
lastCommitData={latestGitHubCommit || null}
|
||||
/>
|
||||
</div>
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings?.SECURITY_ANALYZER}
|
||||
/>
|
||||
{settings && (
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EventHandler>
|
||||
</WsClientProvider>
|
||||
|
||||
@@ -63,10 +63,10 @@ export default function MainApp() {
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings.LANGUAGE) {
|
||||
if (settings?.LANGUAGE) {
|
||||
i18n.changeLanguage(settings.LANGUAGE);
|
||||
}
|
||||
}, [settings.LANGUAGE]);
|
||||
}, [settings?.LANGUAGE]);
|
||||
|
||||
const isInWaitlist =
|
||||
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
|
||||
|
||||
95
frontend/src/routes/app.tsx
Normal file
95
frontend/src/routes/app.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import { FaArrowRotateRight } from "react-icons/fa6";
|
||||
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
|
||||
import { useActiveHost } from "#/hooks/query/use-active-host";
|
||||
import { PathForm } from "#/components/features/served-host/path-form";
|
||||
|
||||
function ServedApp() {
|
||||
const { activeHost } = useActiveHost();
|
||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||
const [currentActiveHost, setCurrentActiveHost] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [path, setPath] = React.useState<string>("hello");
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const handleOnBlur = () => {
|
||||
if (formRef.current) {
|
||||
const formData = new FormData(formRef.current);
|
||||
const urlInputValue = formData.get("url")?.toString();
|
||||
|
||||
if (urlInputValue) {
|
||||
const url = new URL(urlInputValue);
|
||||
|
||||
setCurrentActiveHost(url.origin);
|
||||
setPath(url.pathname);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetUrl = () => {
|
||||
setCurrentActiveHost(activeHost);
|
||||
setPath("");
|
||||
|
||||
if (formRef.current) {
|
||||
formRef.current.reset();
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
resetUrl();
|
||||
}, [activeHost]);
|
||||
|
||||
const fullUrl = `${currentActiveHost}/${path}`;
|
||||
|
||||
if (!currentActiveHost) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-10">
|
||||
<span className="text-neutral-400 font-bold">
|
||||
If you tell OpenHands to start a web server, the app will appear here.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="w-full p-2 flex items-center gap-4 border-b border-neutral-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(fullUrl, "_blank")}
|
||||
className="text-sm"
|
||||
>
|
||||
<FaExternalLinkAlt className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((prev) => prev + 1)}
|
||||
className="text-sm"
|
||||
>
|
||||
<FaArrowRotateRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={() => resetUrl()} className="text-sm">
|
||||
<FaHome className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-full flex">
|
||||
<PathForm
|
||||
ref={formRef}
|
||||
onBlur={handleOnBlur}
|
||||
defaultValue={fullUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
key={refreshKey}
|
||||
title="Served App"
|
||||
src={fullUrl}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServedApp;
|
||||
@@ -4,9 +4,3 @@ export function getTerminalCommand(command: string, hidden: boolean = false) {
|
||||
const event = { action: ActionType.RUN, args: { command, hidden } };
|
||||
return event;
|
||||
}
|
||||
|
||||
export function getGitHubTokenCommand(gitHubToken: string) {
|
||||
const command = `export GITHUB_TOKEN=${gitHubToken}`;
|
||||
const event = getTerminalCommand(command, true);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Here are the list of verified models and providers that we know work well with OpenHands.
|
||||
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic"];
|
||||
export const VERIFIED_MODELS = ["gpt-4o", "claude-3-5-sonnet-20241022"];
|
||||
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic", "deepseek"];
|
||||
export const VERIFIED_MODELS = [
|
||||
"gpt-4o",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"deepseek-chat",
|
||||
];
|
||||
|
||||
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
|
||||
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
|
||||
@@ -21,6 +25,7 @@ export const VERIFIED_ANTHROPIC_MODELS = [
|
||||
"claude-2.1",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { vi } from "vitest";
|
||||
import { AppStore, RootState, rootReducer } from "./src/store";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { ConversationProvider } from "#/context/conversation-context";
|
||||
import { SettingsUpToDateProvider } from "#/context/settings-up-to-date-context";
|
||||
import { SettingsProvider } from "#/context/settings-context";
|
||||
|
||||
// Mock useParams before importing components
|
||||
vi.mock("react-router", async () => {
|
||||
@@ -67,19 +67,19 @@ export function renderWithProviders(
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<SettingsUpToDateProvider>
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SettingsProvider>
|
||||
<ConversationProvider>
|
||||
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
|
||||
</ConversationProvider>
|
||||
</QueryClientProvider>
|
||||
</SettingsUpToDateProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ const selectConversationCard = async (page: Page, index: number) => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("FEATURE_MULTI_CONVERSATION_UI", "true");
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "5");
|
||||
});
|
||||
@@ -111,3 +112,28 @@ test("should redirect to home screen if conversation deos not exist", async ({
|
||||
await page.goto("/conversations/9999");
|
||||
await page.waitForURL("/");
|
||||
});
|
||||
|
||||
test("display the conversation details during a conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const conversationPanelButton = page.getByTestId("toggle-conversation-panel");
|
||||
await expect(conversationPanelButton).toBeVisible();
|
||||
await conversationPanelButton.click();
|
||||
|
||||
const panel = page.getByTestId("conversation-panel");
|
||||
|
||||
// select a conversation
|
||||
const conversationItem = panel.getByTestId("conversation-card").first();
|
||||
await conversationItem.click();
|
||||
|
||||
// panel should close
|
||||
await expect(panel).not.toBeVisible();
|
||||
|
||||
await page.waitForURL("/conversations/1");
|
||||
expect(page.url()).toBe("http://localhost:3001/conversations/1");
|
||||
|
||||
const conversationDetails = page.getByTestId("conversation-card");
|
||||
|
||||
await expect(conversationDetails).toBeVisible();
|
||||
await expect(conversationDetails).toHaveText("Conversation 1");
|
||||
});
|
||||
|
||||
@@ -3,9 +3,21 @@ You are OpenHands agent, a helpful AI assistant that can interact with a compute
|
||||
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
|
||||
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
|
||||
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
|
||||
{{ runtime_info }}
|
||||
</IMPORTANT>
|
||||
{% if repo_instructions %}
|
||||
{% if repo_instructions -%}
|
||||
<REPOSITORY_INSTRUCTIONS>
|
||||
{{ repo_instructions }}
|
||||
</REPOSITORY_INSTRUCTIONS>
|
||||
{% endif %}
|
||||
{% if runtime_info and runtime_info.available_hosts -%}
|
||||
<RUNTIME_INFORMATION>
|
||||
The user has access to the following hosts for accessing a web application,
|
||||
each of which has a corresponding port:
|
||||
{% for host, port in runtime_info.available_hosts.items() -%}
|
||||
* {{ host }} (port {{ port }})
|
||||
{% endfor %}
|
||||
When starting a web server, use the corresponding ports. You should also
|
||||
set any options to allow iframes and CORS requests.
|
||||
</RUNTIME_INFORMATION>
|
||||
{% endif %}
|
||||
|
||||
@@ -20,6 +20,9 @@ DISABLE_COLOR_PRINTING = False
|
||||
|
||||
LOG_ALL_EVENTS = os.getenv('LOG_ALL_EVENTS', 'False').lower() in ['true', '1', 'yes']
|
||||
|
||||
# Controls whether to stream Docker container logs
|
||||
DEBUG_RUNTIME = os.getenv('DEBUG_RUNTIME', 'False').lower() in ['true', '1', 'yes']
|
||||
|
||||
ColorType = Literal[
|
||||
'red',
|
||||
'green',
|
||||
|
||||
@@ -67,6 +67,13 @@ class Event:
|
||||
@timeout.setter
|
||||
def timeout(self, value: int | None) -> None:
|
||||
self._timeout = value
|
||||
if value is not None and value > 600:
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
logger.warning(
|
||||
'Timeout greater than 600 seconds may not be supported by '
|
||||
'the runtime. Consider setting a lower timeout.'
|
||||
)
|
||||
|
||||
# Check if .blocking is an attribute of the event
|
||||
if hasattr(self, 'blocking'):
|
||||
|
||||
@@ -24,7 +24,7 @@ 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('^([-+ ])(.*)$')
|
||||
unified_change = re.compile('^([-+ ])(.*)$', re.MULTILINE)
|
||||
|
||||
context_header_old_line = re.compile(r'^\*\*\* ' + file_timestamp_str + '$')
|
||||
context_header_new_line = re.compile('^--- ' + file_timestamp_str + '$')
|
||||
@@ -606,38 +606,39 @@ def parse_unified_diff(text):
|
||||
h = unified_hunk_start.match(hunk[0])
|
||||
del hunk[0]
|
||||
if h:
|
||||
old = int(h.group(1))
|
||||
if len(h.group(2)) > 0:
|
||||
old_len = int(h.group(2))
|
||||
else:
|
||||
old_len = 0
|
||||
# The hunk header @@ -1,6 +1,6 @@ means:
|
||||
# - Start at line 1 in the old file and show 6 lines
|
||||
# - Start at line 1 in the new file and show 6 lines
|
||||
old = int(h.group(1)) # Starting line in old file
|
||||
old_len = int(h.group(2)) if len(h.group(2)) > 0 else 1 # Number of lines in old file
|
||||
|
||||
new = int(h.group(3))
|
||||
if len(h.group(4)) > 0:
|
||||
new_len = int(h.group(4))
|
||||
else:
|
||||
new_len = 0
|
||||
new = int(h.group(3)) # Starting line in new file
|
||||
new_len = int(h.group(4)) if len(h.group(4)) > 0 else 1 # Number of lines in new file
|
||||
|
||||
h = None
|
||||
break
|
||||
|
||||
# Process each line in the hunk
|
||||
for n in hunk:
|
||||
c = unified_change.match(n)
|
||||
if c:
|
||||
kind = c.group(1)
|
||||
line = c.group(2)
|
||||
# Each line in a unified diff starts with a space (context), + (addition), or - (deletion)
|
||||
# The first character is the kind, the rest is the line content
|
||||
kind = n[0] if len(n) > 0 else ' ' # Empty lines in the hunk are treated as context lines
|
||||
line = n[1:] if len(n) > 1 else ''
|
||||
|
||||
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):
|
||||
changes.append(Change(None, new + i, line, hunk_n))
|
||||
i += 1
|
||||
elif kind == ' ':
|
||||
if r != old_len and i != new_len:
|
||||
changes.append(Change(old + r, new + i, line, hunk_n))
|
||||
r += 1
|
||||
i += 1
|
||||
# Process the line based on its kind
|
||||
if kind == '-' and (r != old_len or r == 0):
|
||||
# Line was removed from the old file
|
||||
changes.append(Change(old + r, None, line, hunk_n))
|
||||
r += 1
|
||||
elif kind == '+' and (i != new_len or i == 0):
|
||||
# Line was added in the new file
|
||||
changes.append(Change(None, new + i, line, hunk_n))
|
||||
i += 1
|
||||
elif kind == ' ':
|
||||
# Context line - exists in both old and new file
|
||||
changes.append(Change(old + r, new + i, line, hunk_n))
|
||||
r += 1
|
||||
i += 1
|
||||
|
||||
if len(changes) > 0:
|
||||
return changes
|
||||
|
||||
@@ -14,12 +14,7 @@ from termcolor import colored
|
||||
|
||||
import openhands
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
AppConfig,
|
||||
LLMConfig,
|
||||
SandboxConfig,
|
||||
)
|
||||
from openhands.core.config import AgentConfig, AppConfig, LLMConfig, SandboxConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import CmdRunAction, MessageAction
|
||||
@@ -153,7 +148,7 @@ async def process_issue(
|
||||
max_iterations: int,
|
||||
llm_config: LLMConfig,
|
||||
output_dir: str,
|
||||
runtime_container_image: str,
|
||||
runtime_container_image: str | None,
|
||||
prompt_template: str,
|
||||
issue_handler: IssueHandlerInterface,
|
||||
repo_instruction: str | None = None,
|
||||
@@ -306,7 +301,7 @@ async def resolve_issue(
|
||||
max_iterations: int,
|
||||
output_dir: str,
|
||||
llm_config: LLMConfig,
|
||||
runtime_container_image: str,
|
||||
runtime_container_image: str | None,
|
||||
prompt_template: str,
|
||||
issue_type: str,
|
||||
repo_instruction: str | None,
|
||||
@@ -583,11 +578,16 @@ def main():
|
||||
default=None,
|
||||
help="Target branch to pull and create PR against (for PRs). If not specified, uses the PR's base branch.",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--is-experimental',
|
||||
type=lambda x: x.lower() == 'true',
|
||||
help='Whether to run in experimental mode.',
|
||||
)
|
||||
|
||||
my_args = parser.parse_args()
|
||||
|
||||
runtime_container_image = my_args.runtime_container_image
|
||||
if runtime_container_image is None:
|
||||
if runtime_container_image is None and not my_args.is_experimental:
|
||||
runtime_container_image = (
|
||||
f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik'
|
||||
)
|
||||
|
||||
@@ -57,7 +57,6 @@ from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCode
|
||||
from openhands.runtime.utils.bash import BashSession
|
||||
from openhands.runtime.utils.files import insert_lines, read_lines
|
||||
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
|
||||
from openhands.runtime.utils.system import check_port_available
|
||||
from openhands.runtime.utils.system_stats import get_system_stats
|
||||
from openhands.utils.async_utils import call_sync_from_async, wait_all
|
||||
|
||||
@@ -435,8 +434,6 @@ if __name__ == '__main__':
|
||||
)
|
||||
# example: python client.py 8000 --working-dir /workspace --plugins JupyterRequirement
|
||||
args = parser.parse_args()
|
||||
os.environ['VSCODE_PORT'] = str(int(args.port) + 1)
|
||||
assert check_port_available(int(os.environ['VSCODE_PORT']))
|
||||
|
||||
plugins_to_load: list[Plugin] = []
|
||||
if args.plugins:
|
||||
|
||||
@@ -269,7 +269,10 @@ class Runtime(FileEditRuntimeMixin):
|
||||
)
|
||||
)
|
||||
|
||||
if 'knowledge' in files:
|
||||
def _is_dir_in_files(dir_name: str, files: list[str]) -> bool:
|
||||
return dir_name in files or f"{dir_name}/" in files
|
||||
|
||||
if _is_dir_in_files('knowledge', files):
|
||||
knowledge_dir = dir_name / 'knowledge'
|
||||
_knowledge_microagents_files = self.list_files(str(knowledge_dir))
|
||||
for fname in _knowledge_microagents_files:
|
||||
@@ -282,7 +285,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
)
|
||||
)
|
||||
|
||||
if 'tasks' in files:
|
||||
if _is_dir_in_files('tasks', files):
|
||||
tasks_dir = dir_name / 'tasks'
|
||||
_tasks_microagents_files = self.list_files(str(tasks_dir))
|
||||
for fname in _tasks_microagents_files:
|
||||
@@ -400,3 +403,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
@property
|
||||
def web_hosts(self) -> dict[str, int]:
|
||||
return {}
|
||||
|
||||
@@ -18,10 +18,12 @@ from openhands.utils.shutdown_listener import (
|
||||
class RemoteRuntimeBuilder(RuntimeBuilder):
|
||||
"""This class interacts with the remote Runtime API for building and managing container images."""
|
||||
|
||||
def __init__(self, api_url: str, api_key: str):
|
||||
def __init__(
|
||||
self, api_url: str, api_key: str, session: requests.Session | None = None
|
||||
):
|
||||
self.api_url = api_url
|
||||
self.api_key = api_key
|
||||
self.session = requests.Session()
|
||||
self.session = session or requests.Session()
|
||||
self.session.headers.update({'X-API-Key': self.api_key})
|
||||
|
||||
def build(
|
||||
@@ -116,9 +118,7 @@ class RemoteRuntimeBuilder(RuntimeBuilder):
|
||||
# Wait before polling again
|
||||
sleep_if_should_continue(30)
|
||||
|
||||
raise AgentRuntimeBuildError(
|
||||
'Build interrupted (likely received SIGTERM or SIGINT).'
|
||||
)
|
||||
raise AgentRuntimeBuildError('Build interrupted')
|
||||
|
||||
def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
|
||||
"""Checks if an image exists in the remote registry using the /image_exists endpoint."""
|
||||
|
||||
@@ -10,9 +10,8 @@ from openhands.core.config import AppConfig
|
||||
from openhands.core.exceptions import (
|
||||
AgentRuntimeDisconnectedError,
|
||||
AgentRuntimeNotFoundError,
|
||||
AgentRuntimeNotReadyError,
|
||||
)
|
||||
from openhands.core.logger import DEBUG
|
||||
from openhands.core.logger import DEBUG, DEBUG_RUNTIME
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.runtime.builder import DockerRuntimeBuilder
|
||||
@@ -22,6 +21,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
from openhands.runtime.impl.docker.containers import remove_all_containers
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.runtime.utils.log_streamer import LogStreamer
|
||||
from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
@@ -29,6 +29,11 @@ from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
|
||||
EXECUTION_SERVER_PORT_RANGE = (30000, 39999)
|
||||
VSCODE_PORT_RANGE = (40000, 49999)
|
||||
APP_PORT_RANGE_1 = (50000, 54999)
|
||||
APP_PORT_RANGE_2 = (55000, 59999)
|
||||
|
||||
|
||||
def remove_all_runtime_containers():
|
||||
remove_all_containers(CONTAINER_NAME_PREFIX)
|
||||
@@ -66,13 +71,17 @@ class DockerRuntime(ActionExecutionClient):
|
||||
atexit.register(remove_all_runtime_containers)
|
||||
|
||||
self.config = config
|
||||
self._host_port = 30000 # initial dummy value
|
||||
self._container_port = 30001 # initial dummy value
|
||||
self._runtime_initialized: bool = False
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
self.status_callback = status_callback
|
||||
|
||||
self._host_port = -1
|
||||
self._container_port = -1
|
||||
self._vscode_port = -1
|
||||
self._app_ports: list[int] = []
|
||||
|
||||
self.docker_client: docker.DockerClient = self._init_docker_client()
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
|
||||
self.base_container_image = self.config.sandbox.base_container_image
|
||||
self.runtime_container_image = self.config.sandbox.runtime_container_image
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
@@ -139,7 +148,10 @@ class DockerRuntime(ActionExecutionClient):
|
||||
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
|
||||
)
|
||||
|
||||
self.log_streamer = LogStreamer(self.container, self.log)
|
||||
if DEBUG_RUNTIME:
|
||||
self.log_streamer = LogStreamer(self.container, self.log)
|
||||
else:
|
||||
self.log_streamer = None
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', f'Waiting for client to become ready at {self.api_url}...')
|
||||
@@ -175,27 +187,34 @@ class DockerRuntime(ActionExecutionClient):
|
||||
def _init_container(self):
|
||||
self.log('debug', 'Preparing to start container...')
|
||||
self.send_status_message('STATUS$PREPARING_CONTAINER')
|
||||
plugin_arg = ''
|
||||
if self.plugins is not None and len(self.plugins) > 0:
|
||||
plugin_arg = (
|
||||
f'--plugins {" ".join([plugin.name for plugin in self.plugins])} '
|
||||
)
|
||||
self._host_port = self._find_available_port()
|
||||
self._container_port = (
|
||||
self._host_port
|
||||
) # in future this might differ from host port
|
||||
|
||||
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
|
||||
self._container_port = self._host_port
|
||||
self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE)
|
||||
self._app_ports = [
|
||||
self._find_available_port(APP_PORT_RANGE_1),
|
||||
self._find_available_port(APP_PORT_RANGE_2),
|
||||
]
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
|
||||
use_host_network = self.config.sandbox.use_host_network
|
||||
network_mode: str | None = 'host' if use_host_network else None
|
||||
|
||||
port_mapping: dict[str, list[dict[str, str]]] | None = (
|
||||
None
|
||||
if use_host_network
|
||||
else {f'{self._container_port}/tcp': [{'HostPort': str(self._host_port)}]}
|
||||
)
|
||||
# Initialize port mappings
|
||||
port_mapping: dict[str, list[dict[str, str]]] | None = None
|
||||
if not use_host_network:
|
||||
port_mapping = {
|
||||
f'{self._container_port}/tcp': [{'HostPort': str(self._host_port)}],
|
||||
}
|
||||
|
||||
if use_host_network:
|
||||
if self.vscode_enabled:
|
||||
port_mapping[f'{self._vscode_port}/tcp'] = [
|
||||
{'HostPort': str(self._vscode_port)}
|
||||
]
|
||||
|
||||
for port in self._app_ports:
|
||||
port_mapping[f'{port}/tcp'] = [{'HostPort': str(port)}]
|
||||
else:
|
||||
self.log(
|
||||
'warn',
|
||||
'Using host network mode. If you are using MacOS, please make sure you have the latest version of Docker Desktop and enabled host network feature: https://docs.docker.com/network/drivers/host/#docker-desktop',
|
||||
@@ -205,17 +224,11 @@ class DockerRuntime(ActionExecutionClient):
|
||||
environment = {
|
||||
'port': str(self._container_port),
|
||||
'PYTHONUNBUFFERED': 1,
|
||||
'VSCODE_PORT': str(self._vscode_port),
|
||||
}
|
||||
if self.config.debug or DEBUG:
|
||||
environment['DEBUG'] = 'true'
|
||||
|
||||
if self.vscode_enabled:
|
||||
# vscode is on port +1 from container port
|
||||
if isinstance(port_mapping, dict):
|
||||
port_mapping[f'{self._container_port + 1}/tcp'] = [
|
||||
{'HostPort': str(self._host_port + 1)}
|
||||
]
|
||||
|
||||
self.log('debug', f'Workspace Base: {self.config.workspace_base}')
|
||||
if (
|
||||
self.config.workspace_mount_path is not None
|
||||
@@ -239,26 +252,17 @@ class DockerRuntime(ActionExecutionClient):
|
||||
f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}',
|
||||
)
|
||||
|
||||
if self.config.sandbox.browsergym_eval_env is not None:
|
||||
browsergym_arg = (
|
||||
f'--browsergym-eval-env {self.config.sandbox.browsergym_eval_env}'
|
||||
)
|
||||
else:
|
||||
browsergym_arg = ''
|
||||
command = get_action_execution_server_startup_command(
|
||||
server_port=self._container_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
use_nice_for_root=False,
|
||||
)
|
||||
|
||||
try:
|
||||
self.container = self.docker_client.containers.run(
|
||||
self.runtime_container_image,
|
||||
command=(
|
||||
f'/openhands/micromamba/bin/micromamba run -n openhands '
|
||||
f'poetry run '
|
||||
f'python -u -m openhands.runtime.action_execution_server {self._container_port} '
|
||||
f'--working-dir "{self.config.workspace_mount_path_in_sandbox}" '
|
||||
f'{plugin_arg}'
|
||||
f'--username {"openhands" if self.config.run_as_openhands else "root"} '
|
||||
f'--user-id {self.config.sandbox.user_id} '
|
||||
f'{browsergym_arg}'
|
||||
),
|
||||
command=command,
|
||||
network_mode=network_mode,
|
||||
ports=port_mapping,
|
||||
working_dir='/openhands/code/', # do not change this!
|
||||
@@ -289,6 +293,8 @@ class DockerRuntime(ActionExecutionClient):
|
||||
'error',
|
||||
f'Error: Instance {self.container_name} FAILED to start container!\n',
|
||||
)
|
||||
self.log('error', str(e))
|
||||
raise e
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error',
|
||||
@@ -299,11 +305,20 @@ class DockerRuntime(ActionExecutionClient):
|
||||
raise e
|
||||
|
||||
def _attach_to_container(self):
|
||||
self._container_port = 0
|
||||
self.container = self.docker_client.containers.get(self.container_name)
|
||||
for port in self.container.attrs['NetworkSettings']['Ports']: # type: ignore
|
||||
self._container_port = int(port.split('/')[0])
|
||||
break
|
||||
port = int(port.split('/')[0])
|
||||
if (
|
||||
port >= EXECUTION_SERVER_PORT_RANGE[0]
|
||||
and port <= EXECUTION_SERVER_PORT_RANGE[1]
|
||||
):
|
||||
self._container_port = port
|
||||
if port >= VSCODE_PORT_RANGE[0] and port <= VSCODE_PORT_RANGE[1]:
|
||||
self._vscode_port = port
|
||||
elif port >= APP_PORT_RANGE_1[0] and port <= APP_PORT_RANGE_1[1]:
|
||||
self._app_ports.append(port)
|
||||
elif port >= APP_PORT_RANGE_2[0] and port <= APP_PORT_RANGE_2[1]:
|
||||
self._app_ports.append(port)
|
||||
self._host_port = self._container_port
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
self.log(
|
||||
@@ -331,9 +346,6 @@ class DockerRuntime(ActionExecutionClient):
|
||||
f'Container {self.container_name} not found.'
|
||||
)
|
||||
|
||||
if not self.log_streamer:
|
||||
raise AgentRuntimeNotReadyError('Runtime client is not ready.')
|
||||
|
||||
self.check_if_alive()
|
||||
|
||||
def close(self, rm_all_containers: bool | None = None):
|
||||
@@ -364,10 +376,10 @@ class DockerRuntime(ActionExecutionClient):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _find_available_port(self, max_attempts=5):
|
||||
port = 39999
|
||||
def _find_available_port(self, port_range, max_attempts=5):
|
||||
port = port_range[1]
|
||||
for _ in range(max_attempts):
|
||||
port = find_available_tcp_port(30000, 39999)
|
||||
port = find_available_tcp_port(port_range[0], port_range[1])
|
||||
if not self._is_port_in_use_docker(port):
|
||||
return port
|
||||
# If no port is found after max_attempts, return the last tried port
|
||||
@@ -378,5 +390,15 @@ class DockerRuntime(ActionExecutionClient):
|
||||
token = super().get_vscode_token()
|
||||
if not token:
|
||||
return None
|
||||
vscode_url = f'http://localhost:{self._host_port + 1}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
|
||||
vscode_url = f'http://localhost:{self._vscode_port}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
return vscode_url
|
||||
|
||||
@property
|
||||
def web_hosts(self):
|
||||
hosts: dict[str, int] = {}
|
||||
|
||||
for port in self._app_ports:
|
||||
hosts[f'http://localhost:{port}'] = port
|
||||
|
||||
return hosts
|
||||
|
||||
@@ -13,7 +13,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils.command import get_remote_startup_command
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.runtime.utils.runtime_build import (
|
||||
BuildFromImageType,
|
||||
prep_build_folder,
|
||||
@@ -203,11 +203,6 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
|
||||
):
|
||||
try:
|
||||
self.log('debug', 'Preparing to start container...')
|
||||
plugin_args = []
|
||||
if plugins is not None and len(plugins) > 0:
|
||||
plugin_args.append('--plugins')
|
||||
plugin_args.extend([plugin.name for plugin in plugins])
|
||||
|
||||
# Combine environment variables
|
||||
environment: dict[str, str | None] = {
|
||||
'port': str(self.container_port),
|
||||
@@ -216,24 +211,13 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
|
||||
if self.config.debug:
|
||||
environment['DEBUG'] = 'true'
|
||||
|
||||
browsergym_args = []
|
||||
if self.config.sandbox.browsergym_eval_env is not None:
|
||||
browsergym_args = [
|
||||
'-browsergym-eval-env',
|
||||
self.config.sandbox.browsergym_eval_env,
|
||||
]
|
||||
|
||||
env_secret = modal.Secret.from_dict(environment)
|
||||
|
||||
self.log('debug', f'Sandbox workspace: {sandbox_workspace_dir}')
|
||||
sandbox_start_cmd = get_remote_startup_command(
|
||||
self.container_port,
|
||||
sandbox_workspace_dir,
|
||||
'openhands' if self.config.run_as_openhands else 'root',
|
||||
self.config.sandbox.user_id,
|
||||
plugin_args,
|
||||
browsergym_args,
|
||||
is_root=not self.config.run_as_openhands, # is_root=True when running as root
|
||||
sandbox_start_cmd = get_action_execution_server_startup_command(
|
||||
server_port=self.container_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
self.log('debug', f'Starting container with command: {sandbox_start_cmd}')
|
||||
self.sandbox = modal.Sandbox.create(
|
||||
|
||||
@@ -19,7 +19,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils.command import get_remote_startup_command
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
@@ -66,10 +66,13 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
)
|
||||
|
||||
self.runtime_builder = RemoteRuntimeBuilder(
|
||||
self.config.sandbox.remote_runtime_api_url, self.config.sandbox.api_key
|
||||
self.config.sandbox.remote_runtime_api_url,
|
||||
self.config.sandbox.api_key,
|
||||
self.session,
|
||||
)
|
||||
self.runtime_id: str | None = None
|
||||
self.runtime_url: str | None = None
|
||||
self.available_hosts: dict[str, int] = {}
|
||||
self._runtime_initialized: bool = False
|
||||
|
||||
def _get_action_execution_server_host(self):
|
||||
@@ -126,7 +129,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
with self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
|
||||
timeout=60,
|
||||
) as response:
|
||||
data = response.json()
|
||||
status = data.get('status')
|
||||
@@ -156,7 +158,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
with self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/registry_prefix',
|
||||
timeout=60,
|
||||
) as response:
|
||||
response_json = response.json()
|
||||
registry_prefix = response_json['registry_prefix']
|
||||
@@ -187,7 +188,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/image_exists',
|
||||
params={'image': self.container_image},
|
||||
timeout=60,
|
||||
) as response:
|
||||
if not response.json()['exists']:
|
||||
raise AgentRuntimeError(
|
||||
@@ -196,22 +196,10 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
|
||||
def _start_runtime(self):
|
||||
# Prepare the request body for the /start endpoint
|
||||
plugin_args = []
|
||||
if self.plugins is not None and len(self.plugins) > 0:
|
||||
plugin_args = ['--plugins'] + [plugin.name for plugin in self.plugins]
|
||||
browsergym_args = []
|
||||
if self.config.sandbox.browsergym_eval_env is not None:
|
||||
browsergym_args = [
|
||||
'--browsergym-eval-env'
|
||||
] + self.config.sandbox.browsergym_eval_env.split(' ')
|
||||
command = get_remote_startup_command(
|
||||
self.port,
|
||||
self.config.workspace_mount_path_in_sandbox,
|
||||
'openhands' if self.config.run_as_openhands else 'root',
|
||||
self.config.sandbox.user_id,
|
||||
plugin_args,
|
||||
browsergym_args,
|
||||
is_root=not self.config.run_as_openhands, # is_root=True when running as root
|
||||
command = get_action_execution_server_startup_command(
|
||||
server_port=self.port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
start_request = {
|
||||
'image': self.container_image,
|
||||
@@ -230,7 +218,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/start',
|
||||
json=start_request,
|
||||
timeout=60,
|
||||
) as response:
|
||||
self._parse_runtime_response(response)
|
||||
self.log(
|
||||
@@ -246,15 +233,18 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/resume',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
timeout=60,
|
||||
):
|
||||
pass
|
||||
self._wait_until_alive()
|
||||
self.setup_initial_env()
|
||||
self.log('debug', 'Runtime resumed.')
|
||||
|
||||
def _parse_runtime_response(self, response: requests.Response):
|
||||
start_response = response.json()
|
||||
self.runtime_id = start_response['runtime_id']
|
||||
self.runtime_url = start_response['url']
|
||||
self.available_hosts = start_response.get('work_hosts', {})
|
||||
|
||||
if 'session_api_key' in start_response:
|
||||
self.session.headers.update(
|
||||
{'X-Session-API-Key': start_response['session_api_key']}
|
||||
@@ -276,6 +266,10 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
)
|
||||
return vscode_url
|
||||
|
||||
@property
|
||||
def web_hosts(self) -> dict[str, int]:
|
||||
return self.available_hosts
|
||||
|
||||
def _wait_until_alive(self):
|
||||
retry_decorator = tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(
|
||||
@@ -293,7 +287,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
with self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
|
||||
timeout=60,
|
||||
) as runtime_info_response:
|
||||
runtime_data = runtime_info_response.json()
|
||||
assert 'runtime_id' in runtime_data
|
||||
@@ -345,7 +338,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
)
|
||||
raise AgentRuntimeNotReadyError()
|
||||
|
||||
def close(self, timeout: int = 10):
|
||||
def close(self):
|
||||
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
|
||||
super().close()
|
||||
return
|
||||
@@ -354,7 +347,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/stop',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
timeout=timeout,
|
||||
):
|
||||
self.log('debug', 'Runtime stopped.')
|
||||
except Exception as e:
|
||||
@@ -388,7 +380,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
elif e.response.status_code == 503:
|
||||
self.log('warning', 'Runtime appears to be paused. Resuming...')
|
||||
self._resume_runtime()
|
||||
self._wait_until_alive()
|
||||
return super()._send_action_server_request(method, url, **kwargs)
|
||||
else:
|
||||
raise e
|
||||
|
||||
@@ -13,7 +13,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils.command import get_remote_startup_command
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
@@ -78,28 +78,10 @@ class RunloopRuntime(ActionExecutionClient):
|
||||
|
||||
def _create_new_devbox(self) -> DevboxView:
|
||||
# Note: Runloop connect
|
||||
sandbox_workspace_dir = self.config.workspace_mount_path_in_sandbox
|
||||
plugin_args = []
|
||||
if self.plugins is not None and len(self.plugins) > 0:
|
||||
plugin_args.append('--plugins')
|
||||
plugin_args.extend([plugin.name for plugin in self.plugins])
|
||||
|
||||
browsergym_args = []
|
||||
if self.config.sandbox.browsergym_eval_env is not None:
|
||||
browsergym_args = [
|
||||
'-browsergym-eval-env',
|
||||
self.config.sandbox.browsergym_eval_env,
|
||||
]
|
||||
|
||||
# Copied from EventstreamRuntime
|
||||
start_command = get_remote_startup_command(
|
||||
self._sandbox_port,
|
||||
sandbox_workspace_dir,
|
||||
'openhands' if self.config.run_as_openhands else 'root',
|
||||
self.config.sandbox.user_id,
|
||||
plugin_args,
|
||||
browsergym_args,
|
||||
is_root=not self.config.run_as_openhands, # is_root=True when running as root
|
||||
start_command = get_action_execution_server_startup_command(
|
||||
server_port=self._sandbox_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
|
||||
# Add some additional commands based on our image
|
||||
@@ -115,13 +97,15 @@ class RunloopRuntime(ActionExecutionClient):
|
||||
|
||||
devbox = self.runloop_api_client.devboxes.create(
|
||||
entrypoint=entrypoint,
|
||||
setup_commands=[f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'],
|
||||
name=self.sid,
|
||||
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
|
||||
prebuilt='openhands',
|
||||
launch_parameters=LaunchParameters(
|
||||
available_ports=[self._sandbox_port, self._vscode_port],
|
||||
resource_size_request='LARGE',
|
||||
launch_commands=[
|
||||
f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'
|
||||
],
|
||||
),
|
||||
metadata={'container-name': self.container_name},
|
||||
)
|
||||
|
||||
@@ -486,18 +486,6 @@ class BashSession:
|
||||
last_change_time = start_time
|
||||
last_pane_output = self._get_pane_content()
|
||||
|
||||
_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
|
||||
assert len(_ps1_matches) >= 1, (
|
||||
'Expected at least one PS1 metadata block BEFORE the execution of a command, '
|
||||
f'but got {len(_ps1_matches)} PS1 metadata blocks:\n---\n{last_pane_output!r}\n---'
|
||||
)
|
||||
if len(_ps1_matches) > 1:
|
||||
logger.warning(
|
||||
'Found multiple PS1 metadata blocks BEFORE the execution of a command. '
|
||||
'Only the last one will be used.'
|
||||
)
|
||||
_ps1_matches = [_ps1_matches[-1]]
|
||||
|
||||
if command != '':
|
||||
# convert command to raw string
|
||||
command = escape_bash_special_chars(command)
|
||||
|
||||
@@ -1,35 +1,57 @@
|
||||
def get_remote_startup_command(
|
||||
port: int,
|
||||
sandbox_workspace_dir: str,
|
||||
username: str,
|
||||
user_id: int,
|
||||
plugin_args: list[str],
|
||||
browsergym_args: list[str],
|
||||
is_root: bool = False,
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
|
||||
DEFAULT_PYTHON_PREFIX = [
|
||||
'/openhands/micromamba/bin/micromamba',
|
||||
'run',
|
||||
'-n',
|
||||
'openhands',
|
||||
'poetry',
|
||||
'run',
|
||||
]
|
||||
|
||||
|
||||
def get_action_execution_server_startup_command(
|
||||
server_port: int,
|
||||
plugins: list[PluginRequirement],
|
||||
app_config: AppConfig,
|
||||
python_prefix: list[str] = DEFAULT_PYTHON_PREFIX,
|
||||
use_nice_for_root: bool = True,
|
||||
):
|
||||
sandbox_config = app_config.sandbox
|
||||
|
||||
# Plugin args
|
||||
plugin_args = []
|
||||
if plugins is not None and len(plugins) > 0:
|
||||
plugin_args = ['--plugins'] + [plugin.name for plugin in plugins]
|
||||
|
||||
# Browsergym stuffs
|
||||
browsergym_args = []
|
||||
if sandbox_config.browsergym_eval_env is not None:
|
||||
browsergym_args = [
|
||||
'--browsergym-eval-env'
|
||||
] + sandbox_config.browsergym_eval_env.split(' ')
|
||||
|
||||
is_root = not app_config.run_as_openhands
|
||||
|
||||
base_cmd = [
|
||||
'/openhands/micromamba/bin/micromamba',
|
||||
'run',
|
||||
'-n',
|
||||
'openhands',
|
||||
'poetry',
|
||||
'run',
|
||||
*python_prefix,
|
||||
'python',
|
||||
'-u',
|
||||
'-m',
|
||||
'openhands.runtime.action_execution_server',
|
||||
str(port),
|
||||
str(server_port),
|
||||
'--working-dir',
|
||||
sandbox_workspace_dir,
|
||||
app_config.workspace_mount_path_in_sandbox,
|
||||
*plugin_args,
|
||||
'--username',
|
||||
username,
|
||||
'openhands' if app_config.run_as_openhands else 'root',
|
||||
'--user-id',
|
||||
str(user_id),
|
||||
str(sandbox_config.user_id),
|
||||
*browsergym_args,
|
||||
]
|
||||
|
||||
if is_root:
|
||||
if is_root and use_nice_for_root:
|
||||
# If running as root, set highest priority and lowest OOM score
|
||||
cmd_str = ' '.join(base_cmd)
|
||||
return [
|
||||
@@ -41,5 +63,5 @@ def get_remote_startup_command(
|
||||
f'echo -1000 > /proc/self/oom_score_adj && exec {cmd_str}',
|
||||
]
|
||||
else:
|
||||
# If not root, run with normal priority
|
||||
# If not root OR not using nice for root, run with normal priority
|
||||
return base_cmd
|
||||
|
||||
@@ -21,7 +21,7 @@ class RequestHTTPError(requests.HTTPError):
|
||||
return s
|
||||
|
||||
|
||||
def is_rate_limit_error(exception):
|
||||
def is_retryable_error(exception):
|
||||
return (
|
||||
isinstance(exception, requests.HTTPError)
|
||||
and exception.response.status_code == 429
|
||||
@@ -29,7 +29,7 @@ def is_rate_limit_error(exception):
|
||||
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception(is_rate_limit_error),
|
||||
retry=retry_if_exception(is_retryable_error),
|
||||
stop=stop_after_attempt(3) | stop_if_should_exit(),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=60),
|
||||
)
|
||||
@@ -48,6 +48,8 @@ def send_request(
|
||||
_json = response.json()
|
||||
except (requests.exceptions.JSONDecodeError, json.decoder.JSONDecodeError):
|
||||
_json = None
|
||||
finally:
|
||||
response.close()
|
||||
raise RequestHTTPError(
|
||||
e,
|
||||
response=e.response,
|
||||
|
||||
@@ -16,12 +16,12 @@ ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl sudo apt-utils git jq tmux \
|
||||
{% if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) %}
|
||||
{%- if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) -%}
|
||||
libgl1 \
|
||||
{% else %}
|
||||
{%- else %}
|
||||
libgl1-mesa-glx \
|
||||
{% endif %}
|
||||
libasound2-plugins libatomic1 curl && \
|
||||
{% endif -%}
|
||||
libasound2-plugins libatomic1 && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ async def connect(connection_id: str, environ, auth):
|
||||
settings = await settings_store.load()
|
||||
|
||||
if not settings:
|
||||
raise ConnectionRefusedError('Settings not found')
|
||||
raise ConnectionRefusedError(
|
||||
'Settings not found', {'msg_id': 'CONFIGURATION$SETTINGS_NOT_FOUND'}
|
||||
)
|
||||
|
||||
try:
|
||||
event_stream = await session_manager.join_conversation(
|
||||
|
||||
@@ -52,6 +52,45 @@ async def get_vscode_url(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@app.get('/web-hosts')
|
||||
async def get_hosts(request: Request):
|
||||
"""Get the hosts used by the runtime.
|
||||
|
||||
This endpoint allows getting the hosts used by the runtime.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastAPI request object.
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response indicating the success of the operation.
|
||||
"""
|
||||
try:
|
||||
if not hasattr(request.state, 'conversation'):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': 'No conversation found in request state'},
|
||||
)
|
||||
|
||||
if not hasattr(request.state.conversation, 'runtime'):
|
||||
return JSONResponse(
|
||||
status_code=500, content={'error': 'No runtime found in conversation'}
|
||||
)
|
||||
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
logger.debug(f'Runtime type: {type(runtime)}')
|
||||
logger.debug(f'Runtime hosts: {runtime.web_hosts}')
|
||||
return JSONResponse(status_code=200, content={'hosts': runtime.web_hosts})
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting runtime hosts: {e}', exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
'hosts': None,
|
||||
'error': f'Error getting runtime hosts: {e}',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get('/events/search')
|
||||
async def search_events(
|
||||
request: Request,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.server.shared import openhands_config
|
||||
@@ -9,9 +7,6 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
|
||||
GITHUB_API_BASE = 'https://api.github.com'
|
||||
GITHUB_API_VERSION = '2022-11-28'
|
||||
|
||||
|
||||
@app.get('/github/repositories')
|
||||
async def get_github_repositories(
|
||||
@@ -69,143 +64,3 @@ async def get_github_repositories(
|
||||
json_response.headers['Link'] = response.headers['Link']
|
||||
|
||||
return json_response
|
||||
|
||||
|
||||
@app.get('/github/installations')
|
||||
async def get_github_installations(request: Request):
|
||||
"""Get GitHub App installations for the authenticated user"""
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {github_token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get, f'{GITHUB_API_BASE}/user/installations', headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error fetching installations: {str(e)}',
|
||||
)
|
||||
|
||||
return JSONResponse(content=response.json())
|
||||
|
||||
|
||||
@app.get('/github/user')
|
||||
async def get_github_user(request: Request):
|
||||
"""Get authenticated GitHub user information"""
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {github_token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get, f'{GITHUB_API_BASE}/user', headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error fetching user: {str(e)}',
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
return JSONResponse(
|
||||
content={
|
||||
'id': data['id'],
|
||||
'login': data['login'],
|
||||
'avatar_url': data['avatar_url'],
|
||||
'company': data.get('company'),
|
||||
'name': data.get('name'),
|
||||
'email': data.get('email'),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get('/github/search/repositories')
|
||||
async def search_github_repositories(
|
||||
request: Request,
|
||||
query: str,
|
||||
per_page: int = Query(default=5, le=100),
|
||||
sort: Literal['', 'updated', 'stars', 'forks'] = 'stars',
|
||||
order: Literal['desc', 'asc'] = 'desc',
|
||||
):
|
||||
"""Search public GitHub repositories"""
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {github_token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
}
|
||||
|
||||
params = {'q': query, 'per_page': str(per_page), 'sort': sort, 'order': order}
|
||||
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get,
|
||||
f'{GITHUB_API_BASE}/search/repositories',
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error searching repositories: {str(e)}',
|
||||
)
|
||||
|
||||
return JSONResponse(content=response.json())
|
||||
|
||||
|
||||
@app.get('/github/repos/{owner}/{repo}/commits')
|
||||
async def get_github_commits(
|
||||
request: Request, owner: str, repo: str, per_page: int = Query(default=1, le=100)
|
||||
):
|
||||
"""Get latest commits for a GitHub repository"""
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {github_token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
}
|
||||
|
||||
params = {'per_page': str(per_page)}
|
||||
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
requests.get,
|
||||
f'{GITHUB_API_BASE}/repos/{owner}/{repo}/commits',
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
response = getattr(e, 'response', None)
|
||||
if response and response.status_code == 409:
|
||||
# Repository is empty, no commits yet
|
||||
return JSONResponse(content=[])
|
||||
raise HTTPException(
|
||||
status_code=getattr(getattr(e, 'response', None), 'status_code', 500),
|
||||
detail=f'Error fetching commits: {str(e)}',
|
||||
)
|
||||
|
||||
return JSONResponse(content=response.json())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
@@ -66,9 +66,10 @@ async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
conversation_id = uuid.uuid4().hex
|
||||
logger.info(f'New conversation ID: {conversation_id}')
|
||||
|
||||
conversation_title = (
|
||||
data.selected_repository or f'Conversation {conversation_id[:5]}'
|
||||
repository_title = (
|
||||
data.selected_repository.split('/')[-1] if data.selected_repository else None
|
||||
)
|
||||
conversation_title = f'{repository_title or "Conversation"} {conversation_id[:5]}'
|
||||
|
||||
logger.info(f'Saving metadata for conversation {conversation_id}')
|
||||
await conversation_store.save_metadata(
|
||||
@@ -221,5 +222,5 @@ def _create_conversation_update_callback(
|
||||
async def _update_timestamp_for_conversation(user_id: int, conversation_id: str):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
conversation = await conversation_store.get_metadata(conversation_id)
|
||||
conversation.last_updated_at = datetime.now()
|
||||
conversation.last_updated_at = datetime.now(timezone.utc)
|
||||
await conversation_store.save_metadata(conversation)
|
||||
|
||||
@@ -180,6 +180,13 @@ class AgentSession:
|
||||
|
||||
logger.debug(f'Initializing runtime `{runtime_name}` now...')
|
||||
runtime_cls = get_runtime_cls(runtime_name)
|
||||
env_vars = (
|
||||
{
|
||||
'GITHUB_TOKEN': github_token,
|
||||
}
|
||||
if github_token
|
||||
else None
|
||||
)
|
||||
self.runtime = runtime_cls(
|
||||
config=config,
|
||||
event_stream=self.event_stream,
|
||||
@@ -187,6 +194,7 @@ class AgentSession:
|
||||
plugins=agent.sandbox_plugins,
|
||||
status_callback=self._status_callback,
|
||||
headless_mode=False,
|
||||
env_vars=env_vars,
|
||||
)
|
||||
|
||||
# FIXME: this sleep is a terrible hack.
|
||||
@@ -208,7 +216,9 @@ class AgentSession:
|
||||
await call_sync_from_async(
|
||||
self.runtime.clone_repo, github_token, selected_repository
|
||||
)
|
||||
|
||||
if agent.prompt_manager:
|
||||
agent.prompt_manager.set_runtime_info(self.runtime)
|
||||
microagents: list[BaseMicroAgent] = await call_sync_from_async(
|
||||
self.runtime.get_microagents_from_selected_repo, selected_repository
|
||||
)
|
||||
|
||||
@@ -165,10 +165,9 @@ class SessionManager:
|
||||
# which can't be guaranteed - nodes can simply vanish unexpectedly!
|
||||
sid = data['sid']
|
||||
logger.debug(f'session_closing:{sid}')
|
||||
for (
|
||||
connection_id,
|
||||
local_sid,
|
||||
) in self.local_connection_id_to_session_id.items():
|
||||
# Create a list of items to process to avoid modifying dict during iteration
|
||||
items = list(self.local_connection_id_to_session_id.items())
|
||||
for connection_id, local_sid in items:
|
||||
if sid == local_sid:
|
||||
logger.warning(
|
||||
'local_connection_to_closing_session:{connection_id}:{sid}'
|
||||
|
||||
@@ -113,9 +113,9 @@ class Session:
|
||||
selected_repository=selected_repository,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f'Error creating controller: {e}')
|
||||
logger.exception(f'Error creating agent_session: {e}')
|
||||
await self.send_error(
|
||||
f'Error creating controller. Please check Docker is running and visit `{TROUBLESHOOTING_URL}` for more debugging information..'
|
||||
f'Error creating agent_session. Please check Docker is running and visit `{TROUBLESHOOTING_URL}` for more debugging information..'
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
|
||||
@@ -13,4 +13,4 @@ class ConversationInfo:
|
||||
last_updated_at: datetime | None = None
|
||||
status: ConversationStatus = ConversationStatus.STOPPED
|
||||
selected_repository: str | None = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -9,4 +9,4 @@ class ConversationMetadata:
|
||||
selected_repository: str | None
|
||||
title: str | None = None
|
||||
last_updated_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -41,6 +41,10 @@ class S3FileStore(FileStore):
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
try:
|
||||
self.client.remove_object(self.bucket, path)
|
||||
client = self.client
|
||||
bucket = self.bucket
|
||||
objects_to_delete = client.list_objects(bucket, prefix=path, recursive=True)
|
||||
for obj in objects_to_delete:
|
||||
client.remove_object(bucket, obj.object_name)
|
||||
except Exception as e:
|
||||
raise FileNotFoundError(f'Failed to delete S3 object at path {path}: {e}')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from itertools import islice
|
||||
|
||||
from jinja2 import Template
|
||||
@@ -11,6 +12,12 @@ from openhands.microagent import (
|
||||
RepoMicroAgent,
|
||||
load_microagents_from_dir,
|
||||
)
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeInfo:
|
||||
available_hosts: dict[str, int]
|
||||
|
||||
|
||||
class PromptManager:
|
||||
@@ -38,6 +45,7 @@ class PromptManager:
|
||||
|
||||
self.system_template: Template = self._load_template('system_prompt')
|
||||
self.user_template: Template = self._load_template('user_prompt')
|
||||
self.runtime_info = RuntimeInfo(available_hosts={})
|
||||
|
||||
self.knowledge_microagents: dict[str, KnowledgeMicroAgent] = {}
|
||||
self.repo_microagents: dict[str, RepoMicroAgent] = {}
|
||||
@@ -72,6 +80,9 @@ class PromptManager:
|
||||
elif isinstance(microagent, RepoMicroAgent):
|
||||
self.repo_microagents[microagent.name] = microagent
|
||||
|
||||
def set_runtime_info(self, runtime: Runtime):
|
||||
self.runtime_info.available_hosts = runtime.web_hosts
|
||||
|
||||
def _load_template(self, template_name: str) -> Template:
|
||||
if self.prompt_dir is None:
|
||||
raise ValueError('Prompt directory is not set')
|
||||
@@ -91,7 +102,9 @@ class PromptManager:
|
||||
if repo_instructions:
|
||||
repo_instructions += '\n\n'
|
||||
repo_instructions += microagent.content
|
||||
return self.system_template.render(repo_instructions=repo_instructions).strip()
|
||||
return self.system_template.render(
|
||||
runtime_info=self.runtime_info, repo_instructions=repo_instructions
|
||||
).strip()
|
||||
|
||||
def get_example_user_message(self) -> str:
|
||||
"""This is the initial user message provided to the agent
|
||||
@@ -103,6 +116,7 @@ class PromptManager:
|
||||
These additional context will convert the current generic agent
|
||||
into a more specialized agent that is tailored to the user's task.
|
||||
"""
|
||||
|
||||
return self.user_template.render().strip()
|
||||
|
||||
def enhance_message(self, message: Message) -> None:
|
||||
|
||||
98
poetry.lock
generated
98
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
@@ -552,17 +552,17 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.35.94"
|
||||
version = "1.35.95"
|
||||
description = "The AWS SDK for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "boto3-1.35.94-py3-none-any.whl", hash = "sha256:516c514fb447d6f216833d06a0781c003fcf43099a4ca2f5a363a8afe0942070"},
|
||||
{file = "boto3-1.35.94.tar.gz", hash = "sha256:5aa606239f0fe0dca0506e0ad6bbe4c589048e7e6c2486cee5ec22b6aa7ec2f8"},
|
||||
{file = "boto3-1.35.95-py3-none-any.whl", hash = "sha256:c81223488607457dacb7829ee0c99803c664593b34a2b0f86c71d421e7c3469a"},
|
||||
{file = "boto3-1.35.95.tar.gz", hash = "sha256:d5671226819f6a78e31b1f37bd60f194afb8203254a543d06bdfb76de7d79236"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.35.94,<1.36.0"
|
||||
botocore = ">=1.35.95,<1.36.0"
|
||||
jmespath = ">=0.7.1,<2.0.0"
|
||||
s3transfer = ">=0.10.0,<0.11.0"
|
||||
|
||||
@@ -571,13 +571,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.35.94"
|
||||
version = "1.35.95"
|
||||
description = "Low-level, data-driven core of boto 3."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "botocore-1.35.94-py3-none-any.whl", hash = "sha256:d784d944865d8279c79d2301fc09ac28b5221d4e7328fb4e23c642c253b9932c"},
|
||||
{file = "botocore-1.35.94.tar.gz", hash = "sha256:2b3309b356541faa4d88bb957dcac1d8004aa44953c0b7d4521a6cc5d3d5d6ba"},
|
||||
{file = "botocore-1.35.95-py3-none-any.whl", hash = "sha256:a672406f748ad6a5b2fb7ea0d8394539eb4fda5332fc5373467d232c4bb27b12"},
|
||||
{file = "botocore-1.35.95.tar.gz", hash = "sha256:b03d2d7cc58a16aa96a7e8f21941b766e98abc6ea74f61a63de7dc26c03641d3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3707,13 +3707,13 @@ types-tqdm = "*"
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.57.2"
|
||||
version = "1.57.4"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
optional = false
|
||||
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
|
||||
files = [
|
||||
{file = "litellm-1.57.2-py3-none-any.whl", hash = "sha256:b572c0d3d3c33ff3a4d18928ac6f051d10ac159814017a817d88ec7af9a8600c"},
|
||||
{file = "litellm-1.57.2.tar.gz", hash = "sha256:0a07c4e288f4bd9033335d5606d7da497f1193d51cf262b96812f40b8842a842"},
|
||||
{file = "litellm-1.57.4-py3-none-any.whl", hash = "sha256:afe48924d8a36db801018970a101622fce33d117fe9c54441c0095c491511abb"},
|
||||
{file = "litellm-1.57.4.tar.gz", hash = "sha256:747a870ddee9c71f9560fc68ad02485bc1008fcad7d7a43e87867a59b8ed0669"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3859,13 +3859,13 @@ llama-index-llms-azure-openai = ">=0.3.0,<0.4.0"
|
||||
|
||||
[[package]]
|
||||
name = "llama-index-embeddings-huggingface"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
description = "llama-index embeddings huggingface integration"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
files = [
|
||||
{file = "llama_index_embeddings_huggingface-0.4.0-py3-none-any.whl", hash = "sha256:a5890bab349b118398054138b298a9e429776b85bcf8017fdf01cd5d60fbba12"},
|
||||
{file = "llama_index_embeddings_huggingface-0.4.0.tar.gz", hash = "sha256:ce8f8b30b29cff85401aba2118285fb63fb8147a56b656ee20f7e8510ca085a2"},
|
||||
{file = "llama_index_embeddings_huggingface-0.5.0-py3-none-any.whl", hash = "sha256:70634b2cfaad28103b5125971fc98118f1bc404cb6145744b55de4ed54b0ad99"},
|
||||
{file = "llama_index_embeddings_huggingface-0.5.0.tar.gz", hash = "sha256:bb75924bd52631364bd3b1a4b0ab78753a0bef00210f2762b425cbd05f4ea60e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4583,12 +4583,12 @@ type = ["mypy (==1.11.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "modal"
|
||||
version = "0.71.8"
|
||||
version = "0.72.0"
|
||||
description = "Python client library for Modal"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "modal-0.71.8-py3-none-any.whl", hash = "sha256:48bd0e9b4f951495fdd1172b8243d477631440a8c01dd13fcab5dd46b36d8a62"},
|
||||
{file = "modal-0.72.0-py3-none-any.whl", hash = "sha256:d09952ac907357d30a314fbf0b759c781ab24677aaf338f5775c9bcab26e4b78"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5364,13 +5364,13 @@ sympy = "*"
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "1.59.4"
|
||||
version = "1.59.5"
|
||||
description = "The official Python library for the openai API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "openai-1.59.4-py3-none-any.whl", hash = "sha256:82113498699998e98104f87c19a890e82df9b01251a0395484360575d3a1d98a"},
|
||||
{file = "openai-1.59.4.tar.gz", hash = "sha256:b946dc5a2308dc1e03efbda80bf1cd64b6053b536851ad519f57ee44401663d2"},
|
||||
{file = "openai-1.59.5-py3-none-any.whl", hash = "sha256:e646b44856b0dda9345d3c43639e056334d792d1690e99690313c0ef7ca4d8cc"},
|
||||
{file = "openai-1.59.5.tar.gz", hash = "sha256:9886e77c02dad9dc6a7b67a11ab372a56842a9b5d376aa476672175ab10e83a0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5389,17 +5389,15 @@ realtime = ["websockets (>=13,<15)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-aci"
|
||||
version = "0.1.6"
|
||||
version = "0.1.8"
|
||||
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.12"
|
||||
files = [
|
||||
{file = "openhands_aci-0.1.6-py3-none-any.whl", hash = "sha256:e9589d959a146fad3e6935be1f80b7a4368dd7aa2ba38ad267862c4f8a246e72"},
|
||||
{file = "openhands_aci-0.1.6.tar.gz", hash = "sha256:6edf4d6478a349140a324c4a0c4be6d1e9a7acce1739a37d02eecbb9006a2ce7"},
|
||||
]
|
||||
python-versions = "^3.12"
|
||||
files = []
|
||||
develop = false
|
||||
|
||||
[package.dependencies]
|
||||
diskcache = ">=5.6.3,<6.0.0"
|
||||
diskcache = "^5.6.3"
|
||||
flake8 = "*"
|
||||
gitpython = "*"
|
||||
grep-ast = "0.3.3"
|
||||
@@ -5409,7 +5407,13 @@ numpy = "*"
|
||||
pandas = "*"
|
||||
scipy = "*"
|
||||
tree-sitter = "0.21.3"
|
||||
whatthepatch = ">=1.0.6,<2.0.0"
|
||||
whatthepatch = "^1.0.6"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/All-Hands-AI/openhands-aci.git"
|
||||
reference = "fix-find-show-only-hidden-subpaths"
|
||||
resolved_reference = "910e8c470aff0e496bf262bc673c7ee7b4531159"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
@@ -7623,29 +7627,29 @@ pyasn1 = ">=0.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.8.6"
|
||||
version = "0.9.0"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3"},
|
||||
{file = "ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1"},
|
||||
{file = "ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807"},
|
||||
{file = "ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25"},
|
||||
{file = "ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d"},
|
||||
{file = "ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75"},
|
||||
{file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315"},
|
||||
{file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188"},
|
||||
{file = "ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf"},
|
||||
{file = "ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117"},
|
||||
{file = "ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe"},
|
||||
{file = "ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d"},
|
||||
{file = "ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a"},
|
||||
{file = "ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76"},
|
||||
{file = "ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764"},
|
||||
{file = "ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905"},
|
||||
{file = "ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162"},
|
||||
{file = "ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5"},
|
||||
{file = "ruff-0.9.0-py3-none-linux_armv6l.whl", hash = "sha256:949b3513f931741e006cf267bf89611edff04e1f012013424022add3ce78f319"},
|
||||
{file = "ruff-0.9.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:99fbcb8c7fe94ae1e462ab2a1ef17cb20b25fb6438b9f198b1bcf5207a0a7916"},
|
||||
{file = "ruff-0.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0b022afd8eb0fcfce1e0adec84322abf4d6ce3cd285b3b99c4f17aae7decf749"},
|
||||
{file = "ruff-0.9.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:336567ce92c9ca8ec62780d07b5fa11fbc881dc7bb40958f93a7d621e7ab4589"},
|
||||
{file = "ruff-0.9.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d338336c44bda602dc8e8766836ac0441e5b0dfeac3af1bd311a97ebaf087a75"},
|
||||
{file = "ruff-0.9.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9b3ececf523d733e90b540e7afcc0494189e8999847f8855747acd5a9a8c45f"},
|
||||
{file = "ruff-0.9.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a11c0872a31232e473e2e0e2107f3d294dbadd2f83fb281c3eb1c22a24866924"},
|
||||
{file = "ruff-0.9.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5fd06220c17a9cc0dc7fc6552f2ac4db74e8e8bff9c401d160ac59d00566f54"},
|
||||
{file = "ruff-0.9.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0457e775c74bf3976243f910805242b7dcd389e1d440deccbd1194ca17a5728c"},
|
||||
{file = "ruff-0.9.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05415599bbcb318f730ea1b46a39e4fbf71f6a63fdbfa1dda92efb55f19d7ecf"},
|
||||
{file = "ruff-0.9.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fbf9864b009e43cfc1c8bed1a6a4c529156913105780af4141ca4342148517f5"},
|
||||
{file = "ruff-0.9.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:37b3da222b12e2bb2ce628e02586ab4846b1ed7f31f42a5a0683b213453b2d49"},
|
||||
{file = "ruff-0.9.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:733c0fcf2eb0c90055100b4ed1af9c9d87305b901a8feb6a0451fa53ed88199d"},
|
||||
{file = "ruff-0.9.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8221a454bfe5ccdf8017512fd6bb60e6ec30f9ea252b8a80e5b73619f6c3cefd"},
|
||||
{file = "ruff-0.9.0-py3-none-win32.whl", hash = "sha256:d345f2178afd192c7991ddee59155c58145e12ad81310b509bd2e25c5b0247b3"},
|
||||
{file = "ruff-0.9.0-py3-none-win_amd64.whl", hash = "sha256:0cbc0905d94d21305872f7f8224e30f4bbcd532bc21b2225b2446d8fc7220d19"},
|
||||
{file = "ruff-0.9.0-py3-none-win_arm64.whl", hash = "sha256:7b1148771c6ca88f820d761350a053a5794bc58e0867739ea93eb5e41ad978cd"},
|
||||
{file = "ruff-0.9.0.tar.gz", hash = "sha256:143f68fa5560ecf10fc49878b73cee3eab98b777fcf43b0e62d43d42f5ef9d8b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9853,4 +9857,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "0d22154d7688e70fbd1a45fc7262b27126cce241e4f0b6739584d80bf863656e"
|
||||
content-hash = "8320b6c6bb05538516a965589ce03fec4d30df38fb7b47fc934258f1d8d47e30"
|
||||
|
||||
@@ -60,12 +60,12 @@ whatthepatch = "^1.0.6"
|
||||
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
|
||||
opentelemetry-api = "1.25.0"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
|
||||
modal = ">=0.66.26,<0.72.0"
|
||||
modal = ">=0.66.26,<0.73.0"
|
||||
runloop-api-client = "0.12.0"
|
||||
libtmux = ">=0.37,<0.40"
|
||||
pygithub = "^2.5.0"
|
||||
joblib = "*"
|
||||
openhands-aci = "0.1.6"
|
||||
openhands-aci = "0.1.8"
|
||||
python-socketio = "^5.11.4"
|
||||
redis = "^5.2.0"
|
||||
sse-starlette = "^2.1.3"
|
||||
@@ -82,7 +82,7 @@ voyageai = "*"
|
||||
llama-index-embeddings-voyageai = "*"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.8.6"
|
||||
ruff = "0.9.0"
|
||||
mypy = "1.14.1"
|
||||
pre-commit = "4.0.1"
|
||||
build = "*"
|
||||
|
||||
47
tests/unit/resolver/test_patch_apply.py
Normal file
47
tests/unit/resolver/test_patch_apply.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
from openhands.resolver.patching.apply import apply_diff
|
||||
from openhands.resolver.patching.exceptions import HunkApplyException
|
||||
from openhands.resolver.patching.patch import parse_diff, diffobj
|
||||
|
||||
|
||||
def test_patch_apply_with_empty_lines():
|
||||
# The original file has no indentation and uses \n line endings
|
||||
original_content = "# PR Viewer\n\nThis React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.\n\n## Setup"
|
||||
|
||||
# The patch has spaces at the start of each line and uses \n line endings
|
||||
patch = """diff --git a/README.md b/README.md
|
||||
index b760a53..5071727 100644
|
||||
--- a/README.md
|
||||
+++ b/README.md
|
||||
@@ -1,3 +1,3 @@
|
||||
# PR Viewer
|
||||
|
||||
-This React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.
|
||||
+This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization."""
|
||||
|
||||
print("Original content lines:")
|
||||
for i, line in enumerate(original_content.splitlines(), 1):
|
||||
print(f"{i}: {repr(line)}")
|
||||
|
||||
print("\nPatch lines:")
|
||||
for i, line in enumerate(patch.splitlines(), 1):
|
||||
print(f"{i}: {repr(line)}")
|
||||
|
||||
changes = parse_diff(patch)
|
||||
print("\nParsed changes:")
|
||||
for change in changes:
|
||||
print(f"Change(old={change.old}, new={change.new}, line={repr(change.line)}, hunk={change.hunk})")
|
||||
diff = diffobj(header=None, changes=changes, text=patch)
|
||||
|
||||
# Apply the patch
|
||||
result = apply_diff(diff, original_content)
|
||||
|
||||
# The patch should be applied successfully
|
||||
expected_result = [
|
||||
"# PR Viewer",
|
||||
"",
|
||||
"This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.",
|
||||
"",
|
||||
"## Setup"
|
||||
]
|
||||
assert result == expected_result
|
||||
112
tests/unit/test_runtime_base.py
Normal file
112
tests/unit/test_runtime_base.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.events import EventStream
|
||||
from openhands.events.observation import FileReadObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.storage.local import LocalFileStore
|
||||
|
||||
|
||||
class MockRuntime(Runtime):
|
||||
async def connect(self) -> None:
|
||||
pass
|
||||
|
||||
def run(self, action):
|
||||
return FileReadObservation('test')
|
||||
|
||||
def run_ipython(self, action):
|
||||
return FileReadObservation('test')
|
||||
|
||||
def read(self, action):
|
||||
return FileReadObservation('test')
|
||||
|
||||
def write(self, action):
|
||||
return FileReadObservation('test')
|
||||
|
||||
def browse(self, action):
|
||||
return FileReadObservation('test')
|
||||
|
||||
def browse_interactive(self, action):
|
||||
return FileReadObservation('test')
|
||||
|
||||
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
|
||||
pass
|
||||
|
||||
def list_files(self, path: str | None = None) -> list[str]:
|
||||
return []
|
||||
|
||||
def copy_from(self, path: str):
|
||||
return Path('test')
|
||||
|
||||
|
||||
class TestRuntime:
|
||||
@pytest.fixture
|
||||
def mock_runtime(self, tmp_path):
|
||||
config = AppConfig()
|
||||
file_store = LocalFileStore(str(tmp_path))
|
||||
event_stream = EventStream('test_sid', file_store)
|
||||
runtime = MockRuntime(config, event_stream)
|
||||
return runtime
|
||||
|
||||
def test_get_microagents_with_trailing_slash(self, mock_runtime):
|
||||
# Mock list_files to return directories with trailing slashes
|
||||
mock_runtime.list_files = MagicMock(side_effect=[
|
||||
['repo.md', 'knowledge/', 'tasks/'], # First call for root dir
|
||||
['test1.md'], # Second call for knowledge dir
|
||||
['test2.md'], # Third call for tasks dir
|
||||
])
|
||||
|
||||
# Mock file read to return some content
|
||||
def mock_read(action):
|
||||
if '.openhands_instructions' in action.path:
|
||||
from openhands.events.observation import ErrorObservation
|
||||
return ErrorObservation('File not found')
|
||||
return FileReadObservation(path='test.md', content="""---
|
||||
name: test
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers: []
|
||||
---
|
||||
test content""")
|
||||
|
||||
mock_runtime.read = MagicMock(side_effect=mock_read)
|
||||
|
||||
# Get microagents
|
||||
microagents = mock_runtime.get_microagents_from_selected_repo(None)
|
||||
|
||||
# Verify that microagents were loaded from both knowledge and tasks directories
|
||||
assert len(microagents) == 3 # 1 from repo.md, 1 from knowledge, 1 from tasks
|
||||
|
||||
def test_get_microagents_without_trailing_slash(self, mock_runtime):
|
||||
# Mock list_files to return directories without trailing slashes
|
||||
mock_runtime.list_files = MagicMock(side_effect=[
|
||||
['repo.md', 'knowledge', 'tasks'], # First call for root dir
|
||||
['test1.md'], # Second call for knowledge dir
|
||||
['test2.md'], # Third call for tasks dir
|
||||
])
|
||||
|
||||
# Mock file read to return some content
|
||||
def mock_read(action):
|
||||
if '.openhands_instructions' in action.path:
|
||||
from openhands.events.observation import ErrorObservation
|
||||
return ErrorObservation('File not found')
|
||||
return FileReadObservation(path='test.md', content="""---
|
||||
name: test
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers: []
|
||||
---
|
||||
test content""")
|
||||
|
||||
mock_runtime.read = MagicMock(side_effect=mock_read)
|
||||
|
||||
# Get microagents
|
||||
microagents = mock_runtime.get_microagents_from_selected_repo(None)
|
||||
|
||||
# Verify that microagents were loaded from both knowledge and tasks directories
|
||||
assert len(microagents) == 3 # 1 from repo.md, 1 from knowledge, 1 from tasks
|
||||
Reference in New Issue
Block a user