Compare commits

..

17 Commits

Author SHA1 Message Date
openhands 367e82c69c Remove feedback module imports and router inclusion 2025-06-09 22:37:44 +00:00
openhands 1fa03176de Update package-lock.json to remove @remix-run/router dependency 2025-06-09 22:34:14 +00:00
openhands e57efa1ef5 Delete feedback.py files instead of keeping them empty 2025-06-09 22:33:00 +00:00
openhands 1601ed5ed8 Remove unintended package dependency 2025-06-09 22:30:09 +00:00
openhands 6d82e446cd Remove comments about feedback functionality 2025-06-09 22:28:15 +00:00
openhands aaf1718406 Remove feedback functionality and thumbs up/down buttons 2025-06-09 22:23:18 +00:00
dependabot[bot] 53b5e08804 chore(deps): bump the version-all group across 1 directory with 15 updates (#9027)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-09 18:04:14 -04:00
Ray Myers 7cee7dca64 chore - log size of large events (#9024) 2025-06-09 16:47:40 -05:00
mamoodi e12a62d006 Update GUI docs (#9020) 2025-06-09 15:38:44 -04:00
llamantino 77a0c5e073 feat: increase requests timeout to 60s (#8974)
Co-authored-by: llamantino <12345678+yourusername@users.noreply.github.com>
2025-06-09 12:42:03 -06:00
Tim O'Farrell e5d21e003d Added environment variable allowing skipping dependency checks (#9010) 2025-06-09 11:14:39 -06:00
mamoodi c6a4324bda Update Cloud API docs (#9008) 2025-06-09 11:42:37 -04:00
Tim O'Farrell 9ac8f011fe Converted exponential backoff to fixed (#9006) 2025-06-09 09:02:52 -06:00
Leander Maben d84befe28f Adding LLM Based Editing capability (#8677)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2025-06-09 21:57:20 +08:00
mamoodi 4eef22e04e Fix some broken links (#9005) 2025-06-09 13:37:00 +00:00
Graham Neubig 93e6811efc Add CLI option to bug template installation dropdown (#9002)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-09 08:49:47 -04:00
Graham Neubig 3ebe3c2140 Update CLI mode documentation to recommend pip install (#8967)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-06-09 08:13:09 -04:00
68 changed files with 715 additions and 2045 deletions
+1
View File
@@ -33,6 +33,7 @@ body:
- Docker command in README
- GitHub resolver
- Development workflow
- CLI
- app.all-hands.dev
- Other
default: 0
+1 -1
View File
@@ -42,7 +42,7 @@
]
},
{
"group": "Running OpenHands Locally",
"group": "Running OpenHands on Your Own",
"pages": [
"usage/local-setup",
"usage/how-to/gui-mode",
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

+79 -85
View File
@@ -1,9 +1,11 @@
---
title: Cloud API
description: OpenHands Cloud provides a REST API that allows you to programmatically interact with the service. This guide explains how to obtain an API key and use the API to start conversations.
description: OpenHands Cloud provides a REST API that allows you to programmatically interact with OpenHands.
This guide explains how to obtain an API key and use the API to start conversations and retrieve their status.
---
For more detailed information about the API, refer to the [OpenHands API Reference](https://docs.all-hands.dev/swagger-ui/).
For the available API endpoints, refer to the
[OpenHands API Reference](https://docs.all-hands.dev/api-reference).
## Obtaining an API Key
@@ -16,7 +18,7 @@ To use the OpenHands Cloud API, you'll need to generate an API key:
5. Give your key a descriptive name (Example: "Development" or "Production") and select `Create`.
6. Copy the generated API key and store it securely. It will only be shown once.
![API Key Generation](/static/img/docs/api-key-generation.png)
![API Key Generation](/static/img/api-key-generation.png)
## API Usage
@@ -33,87 +35,81 @@ To start a new conversation with OpenHands to perform a task, you'll need to mak
#### Examples
<details>
<summary>cURL</summary>
```bash
curl -X POST "https://app.all-hands.dev/api/conversations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}'
```
</details>
<Accordion title="cURL">
```bash
curl -X POST "https://app.all-hands.dev/api/conversations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}'
```
</Accordion>
<details>
<summary>Python (with requests)</summary>
<Accordion title="Python (with requests)">
```python
import requests
```python
import requests
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
data = {
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}
response = requests.post(url, headers=headers, json=data)
conversation = response.json()
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
print(f"Status: {conversation['status']}")
```
</details>
<details>
<summary>TypeScript/JavaScript (with fetch)</summary>
```typescript
const apiKey = "YOUR_API_KEY";
const url = "https://app.all-hands.dev/api/conversations";
const headers = {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
};
const data = {
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
repository: "yourusername/your-repo"
};
async function startConversation() {
try {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(data)
});
const conversation = await response.json();
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
console.log(`Status: ${conversation.status}`);
return conversation;
} catch (error) {
console.error("Error starting conversation:", error);
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
}
startConversation();
```
data = {
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}
</details>
response = requests.post(url, headers=headers, json=data)
conversation = response.json()
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
print(f"Status: {conversation['status']}")
```
</Accordion>
<Accordion title="TypeScript/JavaScript (with fetch)">
```typescript
const apiKey = "YOUR_API_KEY";
const url = "https://app.all-hands.dev/api/conversations";
const headers = {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
};
const data = {
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
repository: "yourusername/your-repo"
};
async function startConversation() {
try {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(data)
});
const conversation = await response.json();
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
console.log(`Status: ${conversation.status}`);
return conversation;
} catch (error) {
console.error("Error starting conversation:", error);
}
}
startConversation();
```
</Accordion>
#### Response
@@ -145,14 +141,12 @@ GET https://app.all-hands.dev/api/conversations/{conversation_id}
#### Example
<details>
<summary>cURL</summary>
```bash
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
```
</details>
<Accordion title="cURL">
```bash
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
```
</Accordion>
#### Response
+1 -1
View File
@@ -26,7 +26,7 @@ The Settings page allows you to:
## Key Features
For an overview of the key features available inside a conversation, please refer to the [Key Features](../key-features)
For an overview of the key features available inside a conversation, please refer to the [Key Features](/usage/key-features)
section of the documentation.
## Next Steps
+20 -5
View File
@@ -1,24 +1,39 @@
---
title: CLI Mode
description: CLI mode provides a powerful interactive Command-Line Interface (CLI) that lets you engage with OpenHands directly from your terminal.
title: CLI
description: The Command-Line Interface (CLI) provides a powerful interface that lets you engage with OpenHands
directly from your terminal.
---
This mode is different from the [headless mode](./headless-mode), which is non-interactive and better for scripting.
This mode is different from the [headless mode](/usage/how-to/headless-mode), which is non-interactive and better
for scripting.
## Getting Started
### Running with Python
1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
1. Install OpenHands using pip:
```bash
pip install openhands-ai
```
2. Set your model, API key, and other preferences using environment variables or with the [`config.toml`](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) file.
3. Launch an interactive OpenHands conversation from the command line:
```bash
poetry run python -m openhands.cli.main
openhands
```
This command opens an interactive prompt where you can type tasks or commands and get responses from OpenHands.
#### For Developers
If you have cloned the repository, you can run the CLI directly using Poetry:
```bash
poetry run python -m openhands.cli.main
```
### Running with Docker
1. Set the following environment variables in your terminal:
+1 -1
View File
@@ -46,7 +46,7 @@ This will produce a new image called `custom-image`, which will be available in
## Using the Docker Command
When running OpenHands using [the docker command](/usage/installation#start-the-app), replace
When running OpenHands using [the docker command](/usage/local-setup#start-the-app), replace
`-e SANDBOX_RUNTIME_CONTAINER_IMAGE=...` with `-e SANDBOX_BASE_CONTAINER_IMAGE=<custom image name>`:
```commandline
+1 -1
View File
@@ -48,6 +48,6 @@ The customization options you can set are:
| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` |
| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` |
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| `TARGET_BRANCH` | Variable | Merge to branch other than `main` | `TARGET_BRANCH="dev"` |
| `TARGET_RUNNER` | Variable | Target runner to execute the agent workflow (default ubuntu-latest) | `TARGET_RUNNER="custom-runner"` |
+46 -46
View File
@@ -1,14 +1,13 @@
---
title: GUI Mode
description: OpenHands provides a Graphical User Interface (GUI) mode for interacting with the AI assistant.
title: GUI
description: High level overview of the Graphical User Interface (GUI) in OpenHands.
---
## Installation and Setup
## Prerequisites
1. Follow the installation instructions to install OpenHands.
2. After running the command, access OpenHands at [http://localhost:3000](http://localhost:3000).
- [OpenHands is running](/usage/local-setup)
## Interacting with the GUI
## Overview
### Initial Setup
@@ -19,16 +18,23 @@ description: OpenHands provides a Graphical User Interface (GUI) mode for intera
3. Enter the corresponding `API Key` for your chosen provider.
4. Click `Save Changes` to apply the settings.
### Version Control Tokens
### Settings
OpenHands supports multiple version control providers. You can configure tokens for multiple providers simultaneously.
You can use the Settings page at any time to:
#### GitHub Token Setup
- Setup the LLM provider and model for OpenHands.
- [Setup the search engine](/usage/search-engine-setup).
- [Configure MCP servers](/usage/mcp).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup) and [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup)
- Set application settings like your preferred language, notifications and other preferences.
- Generate custom secrets.
#### GitHub Setup
OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if provided:
<details>
<summary>Setting Up a GitHub Token</summary>
<AccordionGroup>
<Accordion title="Setting Up a GitHub Token">
1. **Generate a Personal Access Token (PAT)**:
- On GitHub, go to Settings > Developer Settings > Personal Access Tokens > Tokens (classic).
@@ -37,16 +43,11 @@ OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if pro
- `repo` (Full control of private repositories)
- **Fine-Grained Tokens**
- All Repositories (You can select specific repositories, but this will impact what returns in repo search)
- Minimal Permissions ( Select `Meta Data = Read-only` read for search, `Pull Requests = Read and Write` and `Content = Read and Write` for branch creation)
- Minimal Permissions (Select `Meta Data = Read-only` read for search, `Pull Requests = Read and Write` and `Content = Read and Write` for branch creation)
2. **Enter Token in OpenHands**:
- Click the Settings button (gear icon).
- Navigate to the `Git` tab.
- In the Settings page, navigate to the `Git` tab.
- Paste your token in the `GitHub Token` field.
- Click `Save Changes` to apply the changes.
</details>
<details>
<summary>Organizational Token Policies</summary>
If you're working with organizational repositories, additional setup may be required:
@@ -59,15 +60,12 @@ OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if pro
- Look for the organization under `Organization access`.
- If required, click `Enable SSO` next to your organization.
- Complete the SSO authorization process.
</details>
<details>
<summary>Troubleshooting</summary>
</Accordion>
<Accordion title="Troubleshooting">
Common issues and solutions:
- **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.
@@ -81,15 +79,15 @@ OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if pro
- 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.
</details>
</Accordion>
</AccordionGroup>
#### GitLab Token Setup
#### GitLab Setup
OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if provided:
<details>
<summary>Setting Up a GitLab Token</summary>
<AccordionGroup>
<Accordion title="Setting Up a GitLab Token">
1. **Generate a Personal Access Token (PAT)**:
- On GitLab, go to User Settings > Access Tokens.
- Create a new token with the following scopes:
@@ -99,15 +97,12 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
- `write_repository` (Write repository)
- Set an expiration date or leave it blank for a non-expiring token.
2. **Enter Token in OpenHands**:
- Click the Settings button (gear icon).
- Navigate to the `Git` tab.
- In the Settings page, navigate to the `Git` tab.
- Paste your token in the `GitLab Token` field.
- Click `Save Changes` to apply the changes.
</details>
<details>
<summary>Troubleshooting</summary>
</Accordion>
<Accordion title="Troubleshooting">
Common issues and solutions:
- **Token Not Recognized**:
@@ -119,25 +114,30 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
- Verify project access permissions.
- Check if the token has the necessary scopes.
- For group/organization repositories, ensure you have proper access.
</details>
</Accordion>
</AccordionGroup>
### Advanced Settings
#### Advanced Settings
1. Inside the Settings page, under the `LLM` tab, toggle `Advanced` options to access additional settings.
2. Use the `Custom Model` text box to manually enter a model if it's not in the list.
3. Specify a `Base URL` if required by your LLM provider.
The `Advanced` settings allows configuration of additional LLM settings. Inside the Settings page, under the `LLM` tab,
toggle `Advanced` options to access additional settings.
### Interacting with the AI
- Custom Model: Use the `Custom Model` text box to manually enter a model. Make sure to use the correct prefix based on litellm docs.
- Base URL: Specify a `Base URL` if required by your LLM provider.
- Memory Condensation: The memory condenser manages the LLM's context by ensuring only the most important and relevant information is presented.
- Confirmation Mode: Enabling this mode will cause OpenHands to confirm an action with the user before performing it.
1. Type your prompt in the input box.
2. Click the send button or press Enter to submit your message.
3. The AI will process your input and provide a response in the chat window.
4. You can continue the conversation by asking follow-up questions or providing additional information.
### Key Features
For an overview of the key features available inside a conversation, please refer to the [Key Features](/usage/key-features)
section of the documentation.
## Tips for Effective Use
- Be specific in your requests to get the most accurate and helpful responses, as described in the [prompting best practices](../prompting/prompting-best-practices).
- Use one of the recommended models, as described in the [LLMs section](usage/llms/llms.md).
Remember, the GUI mode of OpenHands is designed to make your interaction with the AI assistant as smooth and intuitive
as possible. Don't hesitate to explore its features to maximize your productivity.
## Other Ways to Run Openhands
- [Run OpenHands in a scriptable headless mode.](/usage/how-to/headless-mode)
- [Run OpenHands with a friendly CLI.](/usage/how-to/cli-mode)
- [Run OpenHands on GitHub issues with a GitHub action.](/usage/how-to/github-action)
+4 -3
View File
@@ -1,9 +1,10 @@
---
title: Headless Mode
description: You can run OpenHands with a single command, without starting the web application. This makes it easy to write scripts and automate tasks with OpenHands.
title: Headless
description: You can run OpenHands with a single command, without starting the web application. This makes it easy to
write scripts and automate tasks with OpenHands.
---
This is different from [CLI Mode](./cli-mode), which is interactive, and better for active development.
This is different from [the CLI](./cli-mode), which is interactive, and better for active development.
## With Python
+2 -2
View File
@@ -12,8 +12,8 @@ To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-h
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
## Running OpenHands Locally
## Running OpenHands on Your Own
Run OpenHands on your local system and bring your own LLM and API key.
For more information see [running OpenHands locally.](/usage/local-setup)
For more information see [running OpenHands on your own.](/usage/local-setup)
+1 -1
View File
@@ -48,7 +48,7 @@ We recommend using [LMStudio](https://lmstudio.ai/) for serving these models loc
### Start OpenHands with locally served model
Check [the installation guide](https://docs.all-hands.dev/modules/usage/installation) to make sure you have all the prerequisites for running OpenHands.
Check [the installation guide](/usage/local-setup) to make sure you have all the prerequisites for running OpenHands.
```bash
export LMSTUDIO_MODEL_NAME="imported-models/uncategorized/devstralq4_k_m.gguf" # <- Replace this with the model name you copied from LMStudio
+4 -5
View File
@@ -1,6 +1,6 @@
---
title: Getting Started
description: Getting started with running OpenHands locally.
description: Getting started with running OpenHands on your own.
---
## Recommended Methods for Running Openhands on Your Local System
@@ -62,17 +62,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.40
docker.all-hands.dev/all-hands-ai/openhands:0.41
```
You'll find OpenHands running at http://localhost:3000!
@@ -132,7 +132,6 @@ To enable search functionality in OpenHands:
For more details, see the [Search Engine Setup](/usage/search-engine-setup) guide.
Now you're ready to [get started with OpenHands](/usage/getting-started).
### Versions
+18
View File
@@ -74,6 +74,24 @@ If no condenser configuration is specified, the 'noop' condenser will be used by
For other configurations specific to evaluation, such as `save_trajectory_path`, these are typically set in the `get_config` function of the respective `run_infer.py` file for each benchmark.
### Enabling LLM-Based Editor Tools
The LLM-Based Editor tool (currently supported only for SWE-Bench) can be enabled by setting:
```bash
export ENABLE_LLM_EDITOR=true
```
You can set the config for the Editor LLM as:
```toml
[llm.draft_editor]
base_url = "http://localhost:9002/v1"
model = "hosted_vllm/lite_coder_qwen_editor_3B"
api_key = ""
temperature = 0.7
max_input_tokens = 10500
max_output_tokens = 10500
```
## Supported Benchmarks
The OpenHands evaluation harness supports a wide variety of benchmarks across [software engineering](#software-engineering), [web browsing](#web-browsing), [miscellaneous assistance](#misc-assistance), and [real-world](#real-world) tasks.
+7 -2
View File
@@ -42,7 +42,7 @@ from openhands.core.config import (
AgentConfig,
OpenHandsConfig,
get_llm_config_arg,
get_parser,
get_parser
)
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.utils import get_condenser_config_arg
@@ -62,6 +62,7 @@ from openhands.utils.shutdown_listener import sleep_if_should_continue
USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
RUN_WITH_BROWSING = os.environ.get('RUN_WITH_BROWSING', 'false').lower() == 'true'
ENABLE_LLM_EDITOR = os.environ.get('ENABLE_LLM_EDITOR', 'false').lower() == 'true'
BenchMode = Literal['swe', 'swt', 'swt-ci']
@@ -254,15 +255,19 @@ def get_config(
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config, metadata.eval_output_dir, instance['instance_id']
)
)
# get 'draft_editor' config if exists
config.set_llm_config(get_llm_config_arg('draft_editor'), 'draft_editor')
agent_config = AgentConfig(
enable_jupyter=False,
enable_browsing=RUN_WITH_BROWSING,
enable_llm_editor=False,
enable_llm_editor=ENABLE_LLM_EDITOR,
enable_mcp=False,
condenser=metadata.condenser_config,
enable_prompt_extensions=False,
@@ -1,37 +1,8 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { ChatMessage } from "#/components/features/chat/chat-message";
// Mock the MessageActions component
vi.mock("#/components/features/chat/message-actions", () => ({
MessageActions: ({ onCopy }: { onCopy: () => void }) => (
<div data-testid="message-actions">
<button
data-testid="copy-to-clipboard"
onClick={onCopy}
style={{ display: "none" }}
className="message-action-button"
>
Copy
</button>
</div>
),
}));
// Mock useHover hook
vi.mock("#/hooks/use-hover", () => ({
useHover: () => {
return [
false,
{
onMouseEnter: () => {},
onMouseLeave: () => {},
}
];
},
}));
describe("ChatMessage", () => {
it("should render a user message", () => {
render(<ChatMessage type="user" message="Hello, World!" />);
@@ -52,51 +23,30 @@ describe("ChatMessage", () => {
});
it("should render the copy to clipboard button when the user hovers over the message", async () => {
// This test is now checking for the presence of MessageActions component
// since the copy button visibility is handled there
render(<ChatMessage type="assistant" message="Hello, World!" messageId={1} />);
expect(screen.getByTestId("message-actions")).toBeInTheDocument();
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
const user = userEvent.setup();
render(<ChatMessage type="user" message="Hello, World!" />);
const message = screen.getByText("Hello, World!");
expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible();
await user.hover(message);
expect(screen.getByTestId("copy-to-clipboard")).toBeVisible();
});
it("should copy content to clipboard", async () => {
// Mock clipboard API
const clipboardWriteTextMock = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: clipboardWriteTextMock },
configurable: true
});
// Mock the handleCopyToClipboard function in the MessageActions component
vi.mock("#/components/features/chat/message-actions", () => ({
MessageActions: ({ onCopy }: { onCopy: () => void }) => {
// Call onCopy immediately to simulate the button click
setTimeout(() => onCopy(), 0);
return (
<div data-testid="message-actions">
<button
data-testid="copy-to-clipboard"
onClick={onCopy}
>
Copy
</button>
</div>
);
},
}));
render(<ChatMessage type="assistant" message="Hello, World!" messageId={1} />);
// Wait for the clipboard function to be called
await waitFor(() => {
expect(clipboardWriteTextMock).toHaveBeenCalledWith("Hello, World!");
});
const user = userEvent.setup();
render(<ChatMessage type="user" message="Hello, World!" />);
const copyToClipboardButton = screen.getByTestId("copy-to-clipboard");
await user.click(copyToClipboardButton);
await waitFor(() =>
expect(navigator.clipboard.readText()).resolves.toBe("Hello, World!"),
);
});
it("should display an error toast if copying content to clipboard fails", async () => {
// This test is now a placeholder since the error handling is in the MessageActions component
});
it("should display an error toast if copying content to clipboard fails", async () => {});
it("should render a component passed as a prop", () => {
function Component() {
@@ -1,76 +0,0 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { TrajectoryActions } from "#/components/features/trajectory/trajectory-actions";
describe("TrajectoryActions", () => {
const user = userEvent.setup();
const onPositiveFeedback = vi.fn();
const onNegativeFeedback = vi.fn();
const onExportTrajectory = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render correctly", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const positiveFeedback = screen.getByTestId("positive-feedback");
await user.click(positiveFeedback);
expect(onPositiveFeedback).toHaveBeenCalled();
});
it("should call onNegativeFeedback when negative feedback is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const negativeFeedback = screen.getByTestId("negative-feedback");
await user.click(negativeFeedback);
expect(onNegativeFeedback).toHaveBeenCalled();
});
it("should call onExportTrajectory when export button is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});
@@ -1,68 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock useParams before importing components
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...(actual as object),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { FeedbackForm } from "#/components/features/feedback/feedback-form";
import { I18nKey } from "#/i18n/declaration";
describe("FeedbackForm", () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render correctly", () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
screen.getByLabelText(I18nKey.FEEDBACK$EMAIL_LABEL);
screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
screen.getByRole("button", { name: I18nKey.FEEDBACK$SHARE_LABEL });
screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL });
});
it("should switch between private and public permissions", async () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
const privateRadio = screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
const publicRadio = screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
expect(privateRadio).toBeChecked(); // private is the default value
expect(publicRadio).not.toBeChecked();
await user.click(publicRadio);
expect(publicRadio).toBeChecked();
expect(privateRadio).not.toBeChecked();
await user.click(privateRadio);
expect(privateRadio).toBeChecked();
expect(publicRadio).not.toBeChecked();
});
it("should call onClose when the close button is clicked", async () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
await user.click(
screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }),
);
expect(onCloseMock).toHaveBeenCalled();
});
});
-16
View File
@@ -1,7 +1,5 @@
import { AxiosHeaders } from "axios";
import {
Feedback,
FeedbackResponse,
GitHubAccessTokenResponse,
GetConfigResponse,
GetVSCodeUrlResponse,
@@ -95,20 +93,6 @@ class OpenHands {
return headers;
}
/**
* Send feedback to the server
* @param data Feedback data
* @returns The stored feedback data
*/
static async submitFeedback(
conversationId: string,
feedback: Feedback,
): Promise<FeedbackResponse> {
const url = `/api/conversations/${conversationId}/submit-feedback`;
const { data } = await openHands.post<FeedbackResponse>(url, feedback);
return data;
}
/**
* Authenticate with GitHub token
* @returns Response with authentication status and user info if successful
-20
View File
@@ -14,17 +14,6 @@ export interface FileUploadSuccessResponse {
skipped_files: { name: string; reason: string }[];
}
export interface FeedbackBodyResponse {
message: string;
feedback_id: string;
password: string;
}
export interface FeedbackResponse {
statusCode: number;
body: FeedbackBodyResponse;
}
export interface GitHubAccessTokenResponse {
access_token: string;
}
@@ -34,15 +23,6 @@ export interface AuthenticationResponse {
login?: string; // Only present when allow list is enabled
}
export interface Feedback {
version: string;
email: string;
token: string;
polarity: "positive" | "negative";
permissions: "public" | "private";
trajectory: unknown[];
}
export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
APP_SLUG?: string;
@@ -3,11 +3,10 @@ import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import hotToast from "react-hot-toast";
import { I18nKey } from "#/i18n/declaration";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { createChatMessage, createUserFeedback } from "#/services/chat-service";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
@@ -18,7 +17,6 @@ import { useWsClient } from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ActionSuggestions } from "./action-suggestions";
import { FeedbackModal } from "../feedback/feedback-modal";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -51,10 +49,6 @@ export function ChatInterface() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const { selectedRepository, replayJson } = useSelector(
(state: RootState) => state.initialQuery,
@@ -97,19 +91,6 @@ export function ChatInterface() {
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const onClickShareFeedbackActionButton = (
polarity: "positive" | "negative",
) => {
// Open the feedback modal with the selected polarity
setFeedbackPolarity(polarity);
setFeedbackModalIsOpen(true);
// Track the feedback button click
posthog.capture("feedback_button_clicked", {
polarity,
});
};
const onClickExportTrajectoryButton = () => {
if (!params.conversationId) {
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
@@ -171,12 +152,6 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
/>
@@ -201,29 +176,6 @@ export function ChatInterface() {
onChange={setMessageToSend}
/>
</div>
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => {
// Send the feedback action
send(createUserFeedback(feedbackPolarity, "trajectory"));
// Show a toast notification to confirm feedback was sent
hotToast.success(
feedbackPolarity === "positive"
? t(I18nKey.FEEDBACK$POSITIVE_SENT)
: t(I18nKey.FEEDBACK$NEGATIVE_SENT),
);
// Track the feedback submission
posthog.capture("feedback_submitted", {
polarity: feedbackPolarity,
});
setFeedbackModalIsOpen(false);
}}
polarity={feedbackPolarity}
/>
</div>
);
}
@@ -4,27 +4,22 @@ import remarkGfm from "remark-gfm";
import { code } from "../markdown/code";
import { cn } from "#/utils/utils";
import { ul, ol } from "../markdown/list";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
import { anchor } from "../markdown/anchor";
import { MessageActions } from "./message-actions";
import { useHover } from "#/hooks/use-hover";
import { OpenHandsSourceType } from "#/types/core/base";
import { paragraph } from "../markdown/paragraph";
interface ChatMessageProps {
type: OpenHandsSourceType;
message: string;
messageId?: number;
feedback?: "positive" | "negative" | null;
}
export function ChatMessage({
type,
message,
messageId,
feedback,
children,
}: React.PropsWithChildren<ChatMessageProps>) {
const [isHovering, hoverProps] = useHover();
const [isHovering, setIsHovering] = React.useState(false);
const [isCopy, setIsCopy] = React.useState(false);
const handleCopyToClipboard = async () => {
@@ -49,8 +44,8 @@ export function ChatMessage({
return (
<article
data-testid={`${type}-message`}
onMouseEnter={hoverProps.onMouseEnter}
onMouseLeave={hoverProps.onMouseLeave}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={cn(
"rounded-xl relative",
"flex flex-col gap-2",
@@ -58,17 +53,12 @@ export function ChatMessage({
type === "agent" && "mt-6 max-w-full bg-transparent",
)}
>
{/* Action buttons */}
{type === "assistant" && (
<MessageActions
messageId={messageId}
feedback={feedback}
isHovering={isHovering}
isCopy={isCopy}
onCopy={handleCopyToClipboard}
/>
)}
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopy}
onClick={handleCopyToClipboard}
mode={isCopy ? "copied" : "copy"}
/>
<div className="text-sm break-words">
<Markdown
components={{
@@ -83,7 +73,6 @@ export function ChatMessage({
{message}
</Markdown>
</div>
{children}
</article>
);
@@ -18,7 +18,6 @@ import { ol, ul } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { MonoComponent } from "./mono-component";
import { PathComponent } from "./path-component";
import { FinishActionRating } from "./finish-action-rating";
const trimText = (text: string, maxLength: number): string => {
if (!text) return "";
@@ -204,11 +203,6 @@ export function ExpandableMessage({
>
{details}
</Markdown>
{/* Show rating component for finish actions in SAAS mode */}
{action?.payload.action === "finish" && (
<FinishActionRating messageId={action.payload.id} />
)}
</div>
)}
</div>
@@ -1,137 +0,0 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useWsClient } from "#/context/ws-client-provider";
import { createUserFeedback } from "#/services/chat-service";
import { useConfig } from "#/hooks/query/use-config";
import StarIcon from "#/icons/star.svg?react";
import StarFilledIcon from "#/icons/star-filled.svg?react";
import { I18nKey } from "#/i18n/declaration";
interface FinishActionRatingProps {
messageId: number;
}
// List of reasons for negative feedback with their translation keys
const FEEDBACK_REASONS = [
{ key: I18nKey.FEEDBACK$REASON_NOT_FOLLOW_INSTRUCTION },
{ key: I18nKey.FEEDBACK$REASON_BAD_SOLUTION },
{ key: I18nKey.FEEDBACK$REASON_LACKS_ACCESS },
];
export function FinishActionRating({ messageId }: FinishActionRatingProps) {
const { t } = useTranslation();
const { send } = useWsClient();
const { data: config } = useConfig();
const [rating, setRating] = useState<number | null>(null);
const [hoveredRating, setHoveredRating] = useState<number | null>(null);
const [showReasons, setShowReasons] = useState(false);
const [reasonTimeout, setReasonTimeout] = useState<NodeJS.Timeout | null>(
null,
);
// Clean up timeout on unmount
useEffect(
() => () => {
if (reasonTimeout) {
clearTimeout(reasonTimeout);
}
},
[reasonTimeout],
);
// Submit feedback to the backend
const submitFeedback = (ratingValue: number, reason: string | null) => {
// Convert rating to positive/negative
const feedbackType = ratingValue >= 3 ? "positive" : "negative";
// Send feedback event
if (send) {
send(
createUserFeedback(
feedbackType,
"message",
messageId,
ratingValue,
reason,
),
);
}
// Hide reasons after submission
setShowReasons(false);
};
// Handle rating selection
const handleRatingClick = (value: number) => {
setRating(value);
setShowReasons(true);
// Set a timeout to automatically submit feedback if no reason is selected
const timeout = setTimeout(() => {
submitFeedback(value, null);
}, 3000);
setReasonTimeout(timeout);
};
// Handle reason selection
const handleReasonClick = (reason: string) => {
if (reasonTimeout) {
clearTimeout(reasonTimeout);
}
submitFeedback(rating!, reason);
};
// Only show in SAAS mode
if (config?.APP_MODE !== "saas") {
return null;
}
return (
<div className="mt-2">
{/* Rating stars */}
<div className="flex items-center mb-2">
<span className="text-sm mr-2">{t("FEEDBACK$RATE_RESPONSE")}</span>
<div className="flex">
{[1, 2, 3, 4, 5].map((value) => (
<button
type="button"
key={value}
className="p-1 focus:outline-none"
onMouseEnter={() => setHoveredRating(value)}
onMouseLeave={() => setHoveredRating(null)}
onClick={() => handleRatingClick(value)}
disabled={rating !== null}
>
{(hoveredRating !== null && value <= hoveredRating) ||
(rating !== null && value <= rating) ? (
<StarFilledIcon className="w-5 h-5 text-yellow-400" />
) : (
<StarIcon className="w-5 h-5 text-gray-400" />
)}
</button>
))}
</div>
</div>
{/* Reason selection */}
{showReasons && (
<div className="mt-2 bg-neutral-800 p-2 rounded">
<p className="text-sm mb-2">{t("FEEDBACK$SELECT_REASON")}</p>
<div className="flex flex-col gap-2">
{FEEDBACK_REASONS.map((reason) => (
<button
type="button"
key={reason.key}
className="text-sm text-left p-2 hover:bg-neutral-700 rounded"
onClick={() => handleReasonClick(t(reason.key))}
>
{t(reason.key)}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -1,35 +0,0 @@
import React from "react";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
import { MessageFeedback } from "./message-feedback";
interface MessageActionsProps {
messageId?: number;
feedback?: "positive" | "negative" | null;
isHovering: boolean;
isCopy: boolean;
onCopy: () => void;
}
export function MessageActions({
messageId,
feedback,
isHovering,
isCopy,
onCopy,
}: MessageActionsProps) {
return (
<div
className={`absolute top-1 right-1 flex items-center gap-1 ${!isHovering ? "hidden" : ""}`}
>
{messageId && (
<MessageFeedback messageId={messageId} feedback={feedback} />
)}
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopy}
onClick={onCopy}
mode={isCopy ? "copied" : "copy"}
/>
</div>
);
}
@@ -1,51 +0,0 @@
import React from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useWsClient } from "#/context/ws-client-provider";
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
import { TrajectoryActionButton } from "#/components/shared/buttons/trajectory-action-button";
import { createUserFeedback } from "#/services/chat-service";
import { setMessageFeedback } from "#/state/chat-slice";
import { I18nKey } from "#/i18n/declaration";
interface MessageFeedbackProps {
messageId: number;
feedback?: "positive" | "negative" | null;
}
export function MessageFeedback({ messageId, feedback }: MessageFeedbackProps) {
const { t } = useTranslation();
const { send } = useWsClient();
const dispatch = useDispatch();
const handleFeedback = (feedbackType: "positive" | "negative") => {
// Don't send if already selected
if (feedback === feedbackType) return;
// Update local state
dispatch(setMessageFeedback({ messageId, feedbackType }));
// Send to backend
send(createUserFeedback(feedbackType, "message", messageId));
};
return (
<div className="flex gap-1 mt-2">
<TrajectoryActionButton
testId={`positive-${messageId}`}
onClick={() => handleFeedback("positive")}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
className={feedback === "positive" ? "bg-neutral-700" : ""}
/>
<TrajectoryActionButton
testId={`negative-${messageId}`}
onClick={() => handleFeedback("negative")}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
className={feedback === "negative" ? "bg-neutral-700" : ""}
/>
</div>
);
}
@@ -1,82 +1,60 @@
import React from "react";
import type { Message } from "#/message";
import { ChatMessage } from "#/components/features/chat/chat-message";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
import { ImageCarousel } from "../images/image-carousel";
import { ExpandableMessage } from "./expandable-message";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { I18nKey } from "#/i18n/declaration";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { isOpenHandsAction, isOpenHandsObservation } from "#/types/core/guards";
import { EventMessage } from "./event-message";
import { ChatMessage } from "./chat-message";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
interface MessagesProps {
messages: Message[];
messages: (OpenHandsAction | OpenHandsObservation)[];
isAwaitingUserConfirmation: boolean;
}
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) => {
const { conversationId } = useConversationId();
const { data: conversation } = useUserConversation(conversationId || null);
const { getOptimisticUserMessage } = useOptimisticUserMessage();
// Check if conversation metadata has trigger=resolver
const isResolverTrigger = conversation?.trigger === "resolver";
const optimisticUserMessage = getOptimisticUserMessage();
return messages.map((message, index) => {
const shouldShowConfirmationButtons =
messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation;
const actionHasObservationPair = React.useCallback(
(event: OpenHandsAction | OpenHandsObservation): boolean => {
if (isOpenHandsAction(event)) {
return !!messages.some(
(msg) => isOpenHandsObservation(msg) && msg.cause === event.id,
);
}
const isFirstUserMessageWithResolverTrigger =
index === 0 && message.sender === "user" && isResolverTrigger;
return false;
},
[messages],
);
// Special case: First user message with resolver trigger
if (isFirstUserMessageWithResolverTrigger) {
return (
<div key={index}>
<ExpandableMessage
type="action"
message={message.content}
id={I18nKey.CHAT$RESOLVER_INSTRUCTIONS}
/>
{message.imageUrls && message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
</div>
);
}
return (
<>
{messages.map((message, index) => (
<EventMessage
key={index}
event={message}
hasObservationPair={actionHasObservationPair(message)}
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
isLastMessage={messages.length - 1 === index}
/>
))}
if (message.type === "error" || message.type === "action") {
return (
<div key={index}>
<ExpandableMessage
type={message.type}
id={message.translationID}
message={message.content}
success={message.success}
observation={message.observation}
action={message.action}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
);
}
{optimisticUserMessage && (
<ChatMessage type="user" message={optimisticUserMessage} />
)}
</>
);
},
(prevProps, nextProps) => {
// Prevent re-renders if messages are the same length
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
return (
<ChatMessage
key={index}
type={message.sender}
message={message.content}
messageId={message.eventID}
feedback={message.feedback}
>
{message.imageUrls && message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
);
});
return true;
},
);
@@ -1,152 +0,0 @@
import React from "react";
import hotToast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { Feedback } from "#/api/open-hands.types";
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
import { BrandButton } from "../settings/brand-button";
const FEEDBACK_VERSION = "1.0";
const VIEWER_PAGE = "https://www.all-hands.dev/share";
interface FeedbackFormProps {
onClose: () => void;
polarity: "positive" | "negative";
}
export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
const { t } = useTranslation();
const copiedToClipboardToast = () => {
hotToast(t(I18nKey.FEEDBACK$PASSWORD_COPIED_MESSAGE), {
icon: "📋",
position: "bottom-right",
});
};
const onPressToast = (password: string) => {
navigator.clipboard.writeText(password);
copiedToClipboardToast();
};
const shareFeedbackToast = (
message: string,
link: string,
password: string,
) => {
hotToast(
<div className="flex flex-col gap-1">
<span>{message}</span>
<a
data-testid="toast-share-url"
className="text-blue-500 underline"
onClick={() => onPressToast(password)}
href={link}
target="_blank"
rel="noreferrer"
>
{t(I18nKey.FEEDBACK$GO_TO_FEEDBACK)}
</a>
<span onClick={() => onPressToast(password)} className="cursor-pointer">
{t(I18nKey.FEEDBACK$PASSWORD)}: {password}{" "}
<span className="text-gray-500">
({t(I18nKey.FEEDBACK$COPY_LABEL)})
</span>
</span>
</div>,
{ duration: 10000 },
);
};
const { mutate: submitFeedback, isPending } = useSubmitFeedback();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const formData = new FormData(event.currentTarget);
const email = formData.get("email")?.toString() || "";
const permissions = (formData.get("permissions")?.toString() ||
"private") as "private" | "public";
const feedback: Feedback = {
version: FEEDBACK_VERSION,
email,
polarity,
permissions,
trajectory: [],
token: "",
};
submitFeedback(
{ feedback },
{
onSuccess: (data) => {
const { message, feedback_id, password } = data.body; // eslint-disable-line
const link = `${VIEWER_PAGE}?share_id=${feedback_id}`;
shareFeedbackToast(message, link, password);
onClose();
},
},
);
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6 w-full">
<label className="flex flex-col gap-2">
<span className="text-xs text-neutral-400">
{t(I18nKey.FEEDBACK$EMAIL_LABEL)}
</span>
<input
required
name="email"
type="email"
placeholder={t(I18nKey.FEEDBACK$EMAIL_PLACEHOLDER)}
className="bg-[#27272A] px-3 py-[10px] rounded"
/>
</label>
<div className="flex gap-4 text-neutral-400">
<label className="flex gap-2 cursor-pointer">
<input
name="permissions"
value="private"
type="radio"
defaultChecked
/>
{t(I18nKey.FEEDBACK$PRIVATE_LABEL)}
</label>
<label className="flex gap-2 cursor-pointer">
<input name="permissions" value="public" type="radio" />
{t(I18nKey.FEEDBACK$PUBLIC_LABEL)}
</label>
</div>
<div className="flex gap-2">
<BrandButton
type="submit"
variant="primary"
className="grow"
isDisabled={isPending}
>
{isPending
? t(I18nKey.FEEDBACK$SUBMITTING_LABEL)
: t(I18nKey.FEEDBACK$SHARE_LABEL)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={onClose}
isDisabled={isPending}
>
{t(I18nKey.FEEDBACK$CANCEL_LABEL)}
</BrandButton>
</div>
{isPending && (
<p className="text-sm text-center text-neutral-400">
{t(I18nKey.FEEDBACK$SUBMITTING_MESSAGE)}
</p>
)}
</form>
);
}
@@ -1,35 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import {
BaseModalTitle,
BaseModalDescription,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { FeedbackForm } from "./feedback-form";
interface FeedbackModalProps {
onClose: () => void;
isOpen: boolean;
polarity: "positive" | "negative";
}
export function FeedbackModal({
onClose,
isOpen,
polarity,
}: FeedbackModalProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<ModalBackdrop onClose={onClose}>
<ModalBody className="border border-tertiary">
<BaseModalTitle title={t(I18nKey.FEEDBACK$TITLE)} />
<BaseModalDescription description={t(I18nKey.FEEDBACK$DESCRIPTION)} />
<FeedbackForm onClose={onClose} polarity={polarity} />
</ModalBody>
</ModalBackdrop>
);
}
@@ -35,6 +35,7 @@ export function SettingsSwitch({
type="checkbox"
onChange={(e) => handleToggle(e.target.checked)}
checked={controlledIsToggled ?? isToggled}
defaultChecked={defaultIsToggled}
/>
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />
@@ -1,37 +1,19 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
import ExportIcon from "#/icons/export.svg?react";
import { TrajectoryActionButton } from "#/components/shared/buttons/trajectory-action-button";
interface TrajectoryActionsProps {
onPositiveFeedback: () => void;
onNegativeFeedback: () => void;
onExportTrajectory: () => void;
}
export function TrajectoryActions({
onPositiveFeedback,
onNegativeFeedback,
onExportTrajectory,
}: TrajectoryActionsProps) {
const { t } = useTranslation();
return (
<div data-testid="feedback-actions" className="flex gap-1">
<TrajectoryActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
/>
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
/>
<div data-testid="trajectory-actions" className="flex gap-1">
<TrajectoryActionButton
testId="export-trajectory"
onClick={onExportTrajectory}
@@ -5,7 +5,6 @@ interface TrajectoryActionButtonProps {
onClick: () => void;
icon: React.ReactNode;
tooltip?: string;
className?: string;
}
export function TrajectoryActionButton({
@@ -13,14 +12,13 @@ export function TrajectoryActionButton({
onClick,
icon,
tooltip,
className,
}: TrajectoryActionButtonProps) {
const button = (
<button
type="button"
data-testid={testId}
onClick={onClick}
className={`button-base p-1 hover:bg-neutral-500 ${className || ""}`}
className="button-base p-1 hover:bg-neutral-500"
>
{icon}
</button>
@@ -96,7 +96,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/usage/installation#getting-an-api-key"
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
/>
</div>
@@ -1,22 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { Feedback } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { useConversationId } from "#/hooks/use-conversation-id";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
type SubmitFeedbackArgs = {
feedback: Feedback;
};
export const useSubmitFeedback = () => {
const { conversationId } = useConversationId();
return useMutation({
mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
OpenHands.submitFeedback(conversationId, feedback),
onError: (error) => {
displayErrorToast(error.message);
},
retry: 2,
retryDelay: 500,
});
};
-12
View File
@@ -1,12 +0,0 @@
import { useState } from "react";
export function useHover() {
const [isHovering, setIsHovering] = useState(false);
const hoverProps = {
onMouseEnter: () => setIsHovering(true),
onMouseLeave: () => setIsHovering(false),
};
return [isHovering, hoverProps] as const;
}
-7
View File
@@ -513,8 +513,6 @@ export enum I18nKey {
CONVERSATION$DELETE_WARNING = "CONVERSATION$DELETE_WARNING",
FEEDBACK$TITLE = "FEEDBACK$TITLE",
FEEDBACK$DESCRIPTION = "FEEDBACK$DESCRIPTION",
FEEDBACK$POSITIVE_SENT = "FEEDBACK$POSITIVE_SENT",
FEEDBACK$NEGATIVE_SENT = "FEEDBACK$NEGATIVE_SENT",
EXIT_PROJECT$WARNING = "EXIT_PROJECT$WARNING",
MODEL_SELECTOR$VERIFIED = "MODEL_SELECTOR$VERIFIED",
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
@@ -573,9 +571,4 @@ export enum I18nKey {
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
FEEDBACK$RATE_RESPONSE = "FEEDBACK$RATE_RESPONSE",
FEEDBACK$SELECT_REASON = "FEEDBACK$SELECT_REASON",
FEEDBACK$REASON_NOT_FOLLOW_INSTRUCTION = "FEEDBACK$REASON_NOT_FOLLOW_INSTRUCTION",
FEEDBACK$REASON_BAD_SOLUTION = "FEEDBACK$REASON_BAD_SOLUTION",
FEEDBACK$REASON_LACKS_ACCESS = "FEEDBACK$REASON_LACKS_ACCESS",
}
-112
View File
@@ -8207,38 +8207,6 @@
"de": "Wir schätzen Ihr Feedback. Bitte teilen Sie uns Ihre Gedanken mit.",
"uk": "Ми цінуємо ваш відгук. Будь ласка, поділіться з нами своїми думками."
},
"FEEDBACK$POSITIVE_SENT": {
"en": "Positive feedback sent",
"ja": "ポジティブなフィードバックが送信されました",
"zh-CN": "已发送积极反馈",
"zh-TW": "已發送積極反饋",
"ko-KR": "긍정적인 피드백이 전송되었습니다",
"no": "Positiv tilbakemelding sendt",
"ar": "تم إرسال تعليق إيجابي",
"de": "Positives Feedback gesendet",
"fr": "Commentaire positif envoyé",
"it": "Feedback positivo inviato",
"pt": "Feedback positivo enviado",
"es": "Comentario positivo enviado",
"tr": "Olumlu geri bildirim gönderildi",
"uk": "Позитивний відгук надіслано"
},
"FEEDBACK$NEGATIVE_SENT": {
"en": "Negative feedback sent",
"ja": "ネガティブなフィードバックが送信されました",
"zh-CN": "已发送消极反馈",
"zh-TW": "已發送消極反饋",
"ko-KR": "부정적인 피드백이 전송되었습니다",
"no": "Negativ tilbakemelding sendt",
"ar": "تم إرسال تعليق سلبي",
"de": "Negatives Feedback gesendet",
"fr": "Commentaire négatif envoyé",
"it": "Feedback negativo inviato",
"pt": "Feedback negativo enviado",
"es": "Comentario negativo enviado",
"tr": "Olumsuz geri bildirim gönderildi",
"uk": "Негативний відгук надіслано"
},
"EXIT_PROJECT$WARNING": {
"en": "Are you sure you want to exit this project? Any unsaved changes will be lost.",
"ja": "このプロジェクトを終了してもよろしいですか?保存されていない変更は失われます。",
@@ -9166,85 +9134,5 @@
"tr": "Doğrulama e-postası yeniden gönderilemedi",
"de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden",
"uk": "Не вдалося повторно надіслати лист підтвердження"
},
"FEEDBACK$RATE_RESPONSE": {
"en": "Rate this response:",
"de": "Bewerten Sie diese Antwort:",
"it": "Valuta questa risposta:",
"pt": "Avalie esta resposta:",
"es": "Califica esta respuesta:",
"ja": "この回答を評価してください:",
"zh-CN": "评价此回复:",
"zh-TW": "評價此回覆:",
"ko-KR": "이 응답을 평가하세요:",
"no": "Vurder dette svaret:",
"ar": "قيم هذه الإجابة:",
"fr": "Évaluez cette réponse:",
"tr": "Bu yanıtı değerlendirin:",
"uk": "Оцініть цю відповідь:"
},
"FEEDBACK$SELECT_REASON": {
"en": "Please select a reason:",
"de": "Bitte wählen Sie einen Grund:",
"it": "Seleziona un motivo:",
"pt": "Por favor, selecione um motivo:",
"es": "Por favor, seleccione un motivo:",
"ja": "理由を選択してください:",
"zh-CN": "请选择原因:",
"zh-TW": "請選擇原因:",
"ko-KR": "이유를 선택해 주세요:",
"no": "Vennligst velg en grunn:",
"ar": "الرجاء اختيار سبب:",
"fr": "Veuillez sélectionner une raison:",
"tr": "Lütfen bir neden seçin:",
"uk": "Будь ласка, виберіть причину:"
},
"FEEDBACK$REASON_NOT_FOLLOW_INSTRUCTION": {
"en": "The agent did not follow my instruction",
"de": "Der Agent hat meine Anweisung nicht befolgt",
"it": "L'agente non ha seguito le mie istruzioni",
"pt": "O agente não seguiu minhas instruções",
"es": "El agente no siguió mis instrucciones",
"ja": "エージェントが私の指示に従わなかった",
"zh-CN": "代理未遵循我的指示",
"zh-TW": "代理未遵循我的指示",
"ko-KR": "에이전트가 내 지시를 따르지 않았습니다",
"no": "Agenten fulgte ikke instruksjonene mine",
"ar": "لم يتبع الوكيل تعليماتي",
"fr": "L'agent n'a pas suivi mes instructions",
"tr": "Ajan talimatlarımı takip etmedi",
"uk": "Агент не дотримувався моїх інструкцій"
},
"FEEDBACK$REASON_BAD_SOLUTION": {
"en": "The agent did not implement a good solution",
"de": "Der Agent hat keine gute Lösung implementiert",
"it": "L'agente non ha implementato una buona soluzione",
"pt": "O agente não implementou uma boa solução",
"es": "El agente no implementó una buena solución",
"ja": "エージェントが良い解決策を実装しなかった",
"zh-CN": "代理未实现良好的解决方案",
"zh-TW": "代理未實現良好的解決方案",
"ko-KR": "에이전트가 좋은 해결책을 구현하지 않았습니다",
"no": "Agenten implementerte ikke en god løsning",
"ar": "لم ينفذ الوكيل حلاً جيدًا",
"fr": "L'agent n'a pas implémenté une bonne solution",
"tr": "Ajan iyi bir çözüm uygulamadı",
"uk": "Агент не реалізував хороше рішення"
},
"FEEDBACK$REASON_LACKS_ACCESS": {
"en": "The agent lacks access to software or hardware that is not installable in the runtime to complete the task",
"de": "Dem Agenten fehlt der Zugriff auf Software oder Hardware, die in der Laufzeitumgebung nicht installierbar ist, um die Aufgabe zu erledigen",
"it": "L'agente non ha accesso a software o hardware non installabile nel runtime per completare l'attività",
"pt": "O agente não tem acesso a software ou hardware que não é instalável no tempo de execução para concluir a tarefa",
"es": "El agente no tiene acceso a software o hardware que no se puede instalar en el entorno de ejecución para completar la tarea",
"ja": "エージェントはタスクを完了するためにランタイムにインストールできないソフトウェアまたはハードウェアへのアクセスが不足しています",
"zh-CN": "代理缺乏访问无法在运行时安装的软件或硬件来完成任务",
"zh-TW": "代理缺乏訪問無法在運行時安裝的軟件或硬件來完成任務",
"ko-KR": "에이전트는 런타임에 설치할 수 없는 소프트웨어나 하드웨어에 접근할 수 없어 작업을 완료할 수 없습니다",
"no": "Agenten mangler tilgang til programvare eller maskinvare som ikke kan installeres i kjøretidsmiljøet for å fullføre oppgaven",
"ar": "يفتقر الوكيل إلى الوصول إلى البرامج أو الأجهزة التي لا يمكن تثبيتها في وقت التشغيل لإكمال المهمة",
"fr": "L'agent n'a pas accès à des logiciels ou du matériel qui ne peuvent pas être installés dans l'environnement d'exécution pour accomplir la tâche",
"tr": "Ajan, görevi tamamlamak için çalışma zamanında yüklenemeyen yazılım veya donanıma erişim eksikliği yaşıyor",
"uk": "Агент не має доступу до програмного або апаратного забезпечення, яке неможливо встановити в середовищі виконання для виконання завдання"
}
}
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L439.5 329 543.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 477 B

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L433.6 328.4l26.2 155.6c1.5 9-2.2 18.1-9.6 23.5s-17.3 6-25.3 1.7l-137-73.2L151 509.1c-8.1 4.3-17.9 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l26.2-155.6L31.1 218.2c-6.5-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6L266.3 13.5C270.4 5.2 278.7 0 287.9 0zm0 79L235.4 187.2c-3.5 7.1-10.2 12.1-18.1 13.3L99 217.9 184.9 303c5.5 5.5 8.1 13.3 6.8 21L171.4 443.7l105.2-56.2c7.1-3.8 15.6-3.8 22.6 0l105.2 56.2L384.2 324.1c-1.3-7.7 1.2-15.5 6.8-21l85.9-85.1L358.6 200.5c-7.8-1.2-14.6-6.1-18.1-13.3L287.9 79z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.8749 1.75H3.91861C3.47998 1.75015 3.05528 1.90407 2.71841 2.18499C2.38154 2.4659 2.15382 2.85603 2.07486 3.2875L1.28111 7.6625C1.23166 7.93277 1.2422 8.21062 1.31201 8.47636C1.38182 8.74211 1.50917 8.98927 1.68507 9.20035C1.86097 9.41142 2.08111 9.58126 2.32991 9.69785C2.57872 9.81443 2.8501 9.87491 3.12486 9.875H5.97486L5.62486 10.7688C5.47928 11.1601 5.4308 11.5809 5.48357 11.995C5.53635 12.4092 5.68881 12.8044 5.92787 13.1467C6.16694 13.489 6.48547 13.7683 6.85615 13.9604C7.22683 14.1526 7.63859 14.2519 8.05611 14.25C8.17634 14.2497 8.29394 14.2148 8.39482 14.1494C8.4957 14.084 8.57557 13.9909 8.62486 13.8813L10.4061 9.875H11.8749C12.3721 9.875 12.8491 9.67746 13.2007 9.32583C13.5523 8.97419 13.7499 8.49728 13.7499 8V3.625C13.7499 3.12772 13.5523 2.65081 13.2007 2.29917C12.8491 1.94754 12.3721 1.75 11.8749 1.75ZM9.37486 9.11875L7.67486 12.9438C7.50092 12.8911 7.3396 12.8034 7.20083 12.6861C7.06206 12.5688 6.94878 12.4242 6.86798 12.2615C6.78717 12.0987 6.74055 11.9211 6.73099 11.7396C6.72143 11.5581 6.74912 11.3766 6.81236 11.2062L7.14361 10.3125C7.2142 10.1236 7.23803 9.92041 7.21307 9.72029C7.18811 9.52018 7.1151 9.32907 7.00028 9.16329C6.88546 8.9975 6.73223 8.86196 6.55367 8.76823C6.37511 8.67449 6.17653 8.62535 5.97486 8.625H3.12486C3.03304 8.62515 2.94232 8.60507 2.85914 8.56618C2.77597 8.52729 2.70238 8.47055 2.64361 8.4C2.58341 8.33042 2.5393 8.24841 2.51445 8.15982C2.4896 8.07123 2.48462 7.97824 2.49986 7.8875L3.29361 3.5125C3.32024 3.3669 3.39767 3.23548 3.51212 3.14162C3.62657 3.04777 3.77062 2.99759 3.91861 3H9.37486V9.11875ZM12.4999 8C12.4999 8.16576 12.434 8.32473 12.3168 8.44194C12.1996 8.55915 12.0406 8.625 11.8749 8.625H10.6249V3H11.8749C12.0406 3 12.1996 3.06585 12.3168 3.18306C12.434 3.30027 12.4999 3.45924 12.4999 3.625V8Z"
fill="white" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-5
View File
@@ -1,5 +0,0 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.3125 6.80003C13.1369 6.58918 12.9171 6.41945 12.6687 6.30282C12.4204 6.18619 12.1494 6.1255 11.875 6.12503H9.025L9.375 5.23128C9.52058 4.83995 9.56907 4.41916 9.51629 4.00498C9.46351 3.5908 9.31106 3.19561 9.07199 2.8533C8.83293 2.51099 8.51439 2.23178 8.14371 2.03962C7.77303 1.84746 7.36127 1.74809 6.94375 1.75003C6.82352 1.75028 6.70592 1.7852 6.60504 1.8506C6.50417 1.91601 6.42429 2.00912 6.375 2.11878L4.59375 6.12503H3.125C2.62772 6.12503 2.15081 6.32257 1.79917 6.6742C1.44754 7.02583 1.25 7.50275 1.25 8.00003V12.375C1.25 12.8723 1.44754 13.3492 1.79917 13.7009C2.15081 14.0525 2.62772 14.25 3.125 14.25H11.0812C11.5199 14.2499 11.9446 14.096 12.2815 13.815C12.6183 13.5341 12.846 13.144 12.925 12.7125L13.7188 8.33753C13.7678 8.06714 13.7569 7.78927 13.6867 7.52358C13.6165 7.25788 13.4887 7.01087 13.3125 6.80003ZM4.375 13H3.125C2.95924 13 2.80027 12.9342 2.68306 12.817C2.56585 12.6998 2.5 12.5408 2.5 12.375V8.00003C2.5 7.83427 2.56585 7.6753 2.68306 7.55809C2.80027 7.44088 2.95924 7.37503 3.125 7.37503H4.375V13ZM12.5 8.11253L11.7062 12.4875C11.6796 12.6331 11.6022 12.7646 11.4877 12.8584C11.3733 12.9523 11.2292 13.0024 11.0812 13H5.625V6.88128L7.325 3.05628C7.49999 3.10729 7.6625 3.19403 7.80229 3.31102C7.94207 3.428 8.05608 3.57269 8.13712 3.73596C8.21817 3.89923 8.26449 4.07752 8.27316 4.25959C8.28183 4.44166 8.25266 4.62355 8.1875 4.79378L7.85625 5.68753C7.78567 5.87644 7.76184 6.07962 7.7868 6.27973C7.81176 6.47985 7.88476 6.67095 7.99958 6.83674C8.11441 7.00253 8.26763 7.13807 8.44619 7.2318C8.62475 7.32554 8.82333 7.37468 9.025 7.37503H11.875C11.9668 7.37488 12.0575 7.39496 12.1407 7.43385C12.2239 7.47274 12.2975 7.52948 12.3563 7.60003C12.4165 7.66961 12.4606 7.75162 12.4854 7.84021C12.5103 7.9288 12.5152 8.02179 12.5 8.11253Z"
fill="white" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-1
View File
@@ -12,7 +12,6 @@ export type Message = {
pending?: boolean;
translationID?: string;
eventID?: number;
feedback?: "positive" | "negative" | null;
observation?: PayloadAction<OpenHandsObservation>;
action?: PayloadAction<OpenHandsAction>;
};
+2 -2
View File
@@ -304,7 +304,7 @@ function LlmSettingsScreen() {
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/usage/installation#getting-an-api-key"
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
/>
<SettingsInput
@@ -379,7 +379,7 @@ function LlmSettingsScreen() {
testId="llm-api-key-help-anchor-advanced"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/usage/installation#getting-an-api-key"
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
/>
<SettingsInput
-20
View File
@@ -11,23 +11,3 @@ export function createChatMessage(
};
return event;
}
export function createUserFeedback(
feedbackType: "positive" | "negative",
targetType: "message" | "trajectory",
targetId?: number,
rating?: number,
reason?: string | null,
) {
const event = {
action: ActionType.USER_FEEDBACK,
args: {
feedback_type: feedbackType,
target_type: targetType,
target_id: targetId,
rating,
reason,
},
};
return event;
}
-398
View File
@@ -1,398 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { Message } from "#/message";
import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsEventType } from "#/types/core/base";
import {
CommandObservation,
IPythonObservation,
OpenHandsObservation,
RecallObservation,
} from "#/types/core/observations";
type SliceState = {
messages: Message[];
systemMessage: {
content: string;
tools: Array<Record<string, unknown>> | null;
openhands_version: string | null;
agent_class: string | null;
} | null;
};
const MAX_CONTENT_LENGTH = 1000;
const HANDLED_ACTIONS: OpenHandsEventType[] = [
"run",
"run_ipython",
"write",
"read",
"browse",
"browse_interactive",
"edit",
"user_feedback",
"recall",
"think",
"system",
"call_tool_mcp",
"mcp",
];
function getRiskText(risk: ActionSecurityRisk) {
switch (risk) {
case ActionSecurityRisk.LOW:
return "Low Risk";
case ActionSecurityRisk.MEDIUM:
return "Medium Risk";
case ActionSecurityRisk.HIGH:
return "High Risk";
case ActionSecurityRisk.UNKNOWN:
default:
return "Unknown Risk";
}
}
const initialState: SliceState = {
messages: [],
systemMessage: null,
};
export const chatSlice = createSlice({
name: "chat",
initialState,
reducers: {
addUserMessage(
state,
action: PayloadAction<{
content: string;
imageUrls: string[];
timestamp: string;
pending?: boolean;
}>,
) {
const message: Message = {
type: "thought",
sender: "user",
content: action.payload.content,
imageUrls: action.payload.imageUrls,
timestamp: action.payload.timestamp || new Date().toISOString(),
pending: !!action.payload.pending,
};
// Remove any pending messages
let i = state.messages.length;
while (i) {
i -= 1;
const m = state.messages[i] as Message;
if (m.pending) {
state.messages.splice(i, 1);
}
}
state.messages.push(message);
},
addAssistantMessage(state: SliceState, action: PayloadAction<string>) {
const message: Message = {
type: "thought",
sender: "assistant",
content: action.payload,
imageUrls: [],
timestamp: new Date().toISOString(),
pending: false,
};
state.messages.push(message);
},
addAssistantAction(
state: SliceState,
action: PayloadAction<OpenHandsAction>,
) {
const actionID = action.payload.action;
if (!HANDLED_ACTIONS.includes(actionID)) {
return;
}
const translationID = `ACTION_MESSAGE$${actionID.toUpperCase()}`;
let text = "";
if (actionID === "system") {
// Store the system message in the state
state.systemMessage = {
content: action.payload.args.content,
tools: action.payload.args.tools,
openhands_version: action.payload.args.openhands_version,
agent_class: action.payload.args.agent_class,
};
// Don't add a message for system actions
return;
}
if (actionID === "run") {
text = `Command:\n\`${action.payload.args.command}\``;
} else if (actionID === "run_ipython") {
text = `\`\`\`\n${action.payload.args.code}\n\`\`\``;
} else if (actionID === "write") {
let { content } = action.payload.args;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
text = `${action.payload.args.path}\n${content}`;
} else if (actionID === "browse") {
text = `Browsing ${action.payload.args.url}`;
} else if (actionID === "browse_interactive") {
// Include the browser_actions in the content
text = `**Action:**\n\n\`\`\`python\n${action.payload.args.browser_actions}\n\`\`\``;
} else if (actionID === "recall") {
// skip recall actions
return;
} else if (actionID === "call_tool_mcp") {
// Format MCP action with name and arguments
const name = action.payload.args.name || "";
const args = action.payload.args.arguments || {};
text = `**MCP Tool Call:** ${name}\n\n`;
// Include thought if available
if (action.payload.args.thought) {
text += `\n\n**Thought:**\n${action.payload.args.thought}`;
}
text += `\n\n**Arguments:**\n\`\`\`json\n${JSON.stringify(args, null, 2)}\n\`\`\``;
}
if (actionID === "run" || actionID === "run_ipython") {
if (
action.payload.args.confirmation_state === "awaiting_confirmation"
) {
text += `\n\n${getRiskText(action.payload.args.security_risk as unknown as ActionSecurityRisk)}`;
}
} else if (actionID === "think") {
text = action.payload.args.thought;
}
const message: Message = {
type: "action",
sender: "assistant",
translationID,
eventID: action.payload.id,
content: text,
imageUrls: [],
timestamp: new Date().toISOString(),
action,
};
state.messages.push(message);
},
addAssistantObservation(
state: SliceState,
observation: PayloadAction<OpenHandsObservation>,
) {
const observationID = observation.payload.observation;
if (!HANDLED_ACTIONS.includes(observationID)) {
return;
}
// Special handling for RecallObservation - create a new message instead of updating an existing one
if (observationID === "recall") {
const recallObs = observation.payload as RecallObservation;
let content = ``;
// Handle workspace context
if (recallObs.extras.recall_type === "workspace_context") {
if (recallObs.extras.repo_name) {
content += `\n\n**Repository:** ${recallObs.extras.repo_name}`;
}
if (recallObs.extras.repo_directory) {
content += `\n\n**Directory:** ${recallObs.extras.repo_directory}`;
}
if (recallObs.extras.date) {
content += `\n\n**Date:** ${recallObs.extras.date}`;
}
if (
recallObs.extras.runtime_hosts &&
Object.keys(recallObs.extras.runtime_hosts).length > 0
) {
content += `\n\n**Available Hosts**`;
for (const [host, port] of Object.entries(
recallObs.extras.runtime_hosts,
)) {
content += `\n\n- ${host} (port ${port})`;
}
}
if (
recallObs.extras.custom_secrets_descriptions &&
Object.keys(recallObs.extras.custom_secrets_descriptions).length > 0
) {
content += `\n\n**Custom Secrets**`;
for (const [name, description] of Object.entries(
recallObs.extras.custom_secrets_descriptions,
)) {
content += `\n\n- $${name}: ${description}`;
}
}
if (recallObs.extras.repo_instructions) {
content += `\n\n**Repository Instructions:**\n\n${recallObs.extras.repo_instructions}`;
}
if (recallObs.extras.additional_agent_instructions) {
content += `\n\n**Additional Instructions:**\n\n${recallObs.extras.additional_agent_instructions}`;
}
}
// Create a new message for the observation
// Use the correct translation ID format that matches what's in the i18n file
const translationID = `OBSERVATION_MESSAGE$${observationID.toUpperCase()}`;
// Handle microagent knowledge
if (
recallObs.extras.microagent_knowledge &&
recallObs.extras.microagent_knowledge.length > 0
) {
content += `\n\n**Triggered Microagent Knowledge:**`;
for (const knowledge of recallObs.extras.microagent_knowledge) {
content += `\n\n- **${knowledge.name}** (triggered by keyword: ${knowledge.trigger})\n\n\`\`\`\n${knowledge.content}\n\`\`\``;
}
}
const message: Message = {
type: "action",
sender: "assistant",
translationID,
eventID: observation.payload.id,
content,
imageUrls: [],
timestamp: new Date().toISOString(),
success: true,
};
state.messages.push(message);
return; // Skip the normal observation handling below
}
// Normal handling for other observation types
const translationID = `OBSERVATION_MESSAGE$${observationID.toUpperCase()}`;
const causeID = observation.payload.cause;
const causeMessage = state.messages.find(
(message) => message.eventID === causeID,
);
if (!causeMessage) {
return;
}
causeMessage.translationID = translationID;
causeMessage.observation = observation;
// Set success property based on observation type
if (observationID === "run") {
const commandObs = observation.payload as CommandObservation;
// If exit_code is -1, it means the command timed out, so we set success to undefined
// to not show any status indicator
if (commandObs.extras.metadata.exit_code === -1) {
causeMessage.success = undefined;
} else {
causeMessage.success = commandObs.extras.metadata.exit_code === 0;
}
} else if (observationID === "run_ipython") {
// For IPython, we consider it successful if there's no error message
const ipythonObs = observation.payload as IPythonObservation;
causeMessage.success = !ipythonObs.content
.toLowerCase()
.includes("error:");
} else if (observationID === "read" || observationID === "edit") {
// For read/edit operations, we consider it successful if there's content and no error
if (observation.payload.extras.impl_source === "oh_aci") {
causeMessage.success =
observation.payload.content.length > 0 &&
!observation.payload.content.startsWith("ERROR:\n");
} else {
causeMessage.success =
observation.payload.content.length > 0 &&
!observation.payload.content.toLowerCase().includes("error:");
}
}
if (observationID === "run" || observationID === "run_ipython") {
let { content } = observation.payload;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
content = `${causeMessage.content}\n\nOutput:\n\`\`\`\n${content.trim() || "[Command finished execution with no output]"}\n\`\`\``;
causeMessage.content = content; // Observation content includes the action
} else if (observationID === "read") {
causeMessage.content = `\`\`\`\n${observation.payload.content}\n\`\`\``; // Content is already truncated by the ACI
} else if (observationID === "edit") {
if (causeMessage.success) {
causeMessage.content = `\`\`\`diff\n${observation.payload.extras.diff}\n\`\`\``; // Content is already truncated by the ACI
} else {
causeMessage.content = observation.payload.content;
}
} else if (observationID === "browse") {
let content = `**URL:** ${observation.payload.extras.url}\n`;
if (observation.payload.extras.error) {
content += `\n\n**Error:**\n${observation.payload.extras.error}\n`;
}
content += `\n\n**Output:**\n${observation.payload.content}`;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
}
causeMessage.content = content;
} else if (observationID === "mcp") {
// For MCP observations, we want to show the content as formatted output
// similar to how run/run_ipython actions are handled
let { content } = observation.payload;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
content = `${causeMessage.content}\n\n**Output:**\n\`\`\`\n${content.trim() || "[MCP Tool finished execution with no output]"}\n\`\`\``;
causeMessage.content = content; // Observation content includes the action
// Set success based on whether there's an error message
causeMessage.success = !observation.payload.content
.toLowerCase()
.includes("error:");
}
},
addErrorMessage(
state: SliceState,
action: PayloadAction<{ id?: string; message: string }>,
) {
const { id, message } = action.payload;
state.messages.push({
translationID: id,
content: message,
type: "error",
sender: "assistant",
timestamp: new Date().toISOString(),
});
},
clearMessages(state: SliceState) {
state.messages = [];
state.systemMessage = null;
},
setMessageFeedback(
state: SliceState,
action: PayloadAction<{
messageId: number;
feedbackType: "positive" | "negative";
}>,
) {
const { messageId, feedbackType } = action.payload;
const messageIndex = state.messages.findIndex(
(message) => message.eventID === messageId,
);
if (messageIndex !== -1) {
state.messages[messageIndex].feedback = feedbackType;
}
},
},
});
export const {
addUserMessage,
addAssistantMessage,
addAssistantAction,
addAssistantObservation,
addErrorMessage,
clearMessages,
setMessageFeedback,
} = chatSlice.actions;
// Selectors
export const selectSystemMessage = (state: { chat: SliceState }) =>
state.chat.systemMessage;
export default chatSlice.reducer;
-3
View File
@@ -42,9 +42,6 @@ enum ActionType {
// Changes the state of the agent, e.g. to paused or running
CHANGE_AGENT_STATE = "change_agent_state",
// User feedback on messages or the entire trajectory
USER_FEEDBACK = "user_feedback",
// Interact with the MCP server.
MCP = "call_tool_mcp",
}
-13
View File
@@ -143,18 +143,6 @@ export interface RejectAction extends OpenHandsActionEvent<"reject"> {
};
}
export interface UserFeedbackAction
extends OpenHandsActionEvent<"user_feedback"> {
source: "user";
args: {
feedback_type: "positive" | "negative";
target_type: "message" | "trajectory";
target_id?: number; // Event ID for message feedback, null for trajectory feedback
rating?: number; // 1-5 rating for SAAS mode
reason?: string | null; // Reason for the rating in SAAS mode
};
}
export interface RecallAction extends OpenHandsActionEvent<"recall"> {
source: "agent";
args: {
@@ -188,6 +176,5 @@ export type OpenHandsAction =
| FileEditAction
| FileWriteAction
| RejectAction
| UserFeedbackAction
| RecallAction
| MCPAction;
-1
View File
@@ -15,7 +15,6 @@ export type OpenHandsEventType =
| "think"
| "finish"
| "error"
| "user_feedback"
| "recall"
| "mcp"
| "call_tool_mcp"
+2 -2
View File
@@ -12,7 +12,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$SETUP_SCRIPT,
link: "https://docs.all-hands.dev/usage/customization/repository",
link: "https://docs.all-hands.dev/usage/prompting/repository#setup-script",
},
{ key: I18nKey.TIPS$VSCODE_INSTANCE },
{ key: I18nKey.TIPS$SAVE_WORK },
@@ -38,7 +38,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$API_USAGE,
link: "https://docs.all-hands.dev/swagger-ui/",
link: "https://docs.all-hands.dev/api-reference/health-check",
},
];
-1
View File
@@ -37,5 +37,4 @@ When creating a new microagent:
For detailed information, see:
- [Microagents Overview](https://docs.all-hands.dev/usage/prompting/microagents-overview)
- [Microagents Syntax](https://docs.all-hands.dev/usage/prompting/microagents-syntax)
- [Example GitHub Microagent](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/github.md)
@@ -141,6 +141,9 @@ def response_to_actions(
content=arguments['content'],
start=arguments.get('start', 1),
end=arguments.get('end', -1),
impl_source=arguments.get(
'impl_source', FileEditSource.LLM_BASED_EDIT
),
)
elif (
tool_call.function.name
@@ -2,10 +2,18 @@ from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChun
_FILE_EDIT_DESCRIPTION = """Edit a file in plain-text format.
* The assistant can edit files by specifying the file path and providing a draft of the new file content.
* The draft content doesn't need to be exactly the same as the existing file; the assistant may skip unchanged lines using comments like `# unchanged` to indicate unchanged sections.
* The draft content doesn't need to be exactly the same as the existing file; the assistant may skip unchanged lines using comments like `# ... existing code ...` to indicate unchanged sections.
* IMPORTANT: For large files (e.g., > 300 lines), specify the range of lines to edit using `start` and `end` (1-indexed, inclusive). The range should be smaller than 300 lines.
* -1 indicates the last line of the file when used as the `start` or `end` value.
* Keep at least one unchanged line before the changed section and after the changed section wherever possible.
* Make sure to set the `start` and `end` to include all the lines in the original file referred to in the draft of the new file content. Failure to do so will result in bad edits.
* To append to a file, set both `start` and `end` to `-1`.
* If the file doesn't exist, a new file will be created with the provided content.
* IMPORTANT: Make sure you include all the required indentations for each line of code in the draft, otherwise the edited code will be incorrectly indented.
* IMPORTANT: Make sure that the first line of the draft is also properly indented and has the required whitespaces.
* IMPORTANT: NEVER include or make references to lines from outside the `start` and `end` range in the draft.
* IMPORTANT: Start the content with a comment in the format: #EDIT: Reason for edit
* IMPORTANT: If you are not appending to the file, avoid setting `start` and `end` to the same value.
**Example 1: general edit for short files**
For example, given an existing file `/path/to/file.py` that looks like this:
@@ -33,13 +41,12 @@ The assistant wants to edit the file to look like this:
The assistant may produce an edit action like this:
path="/path/to/file.txt" start=1 end=-1
content=```
#EDIT: I want to change the value of y to 2
class MyClass:
def __init__(self):
# no changes before
# ... existing code ...
self.y = 2
# self.z is removed
# MyClass().z is removed
print(MyClass().y)
```
@@ -58,6 +65,7 @@ For example, given an existing file `/path/to/file.py` that looks like this:
To append the following lines to the file:
```python
#EDIT: I want to print the value of y
print(MyClass().y)
```
@@ -93,9 +101,9 @@ The assistant wants to edit the file to look like this:
(2000 more lines below)
The assistant may produce an edit action like this:
path="/path/to/file.txt" start=1001 end=1008
path="/path/to/file.txt" start=1002 end=1008
content=```
class MyClass:
#EDIT: I want to change the value of y to 2
def __init__(self):
# no changes before
self.y = 2
-3
View File
@@ -91,6 +91,3 @@ class ActionType(str, Enum):
CONDENSATION = 'condensation'
"""Condenses a list of events into a summary."""
USER_FEEDBACK = 'user_feedback'
"""User feedback on messages or the entire trajectory."""
-2
View File
@@ -10,7 +10,6 @@ from openhands.events.action.agent import (
from openhands.events.action.browse import BrowseInteractiveAction, BrowseURLAction
from openhands.events.action.commands import CmdRunAction, IPythonRunCellAction
from openhands.events.action.empty import NullAction
from openhands.events.action.feedback import UserFeedbackAction
from openhands.events.action.files import (
FileEditAction,
FileReadAction,
@@ -39,5 +38,4 @@ __all__ = [
'AgentThinkAction',
'RecallAction',
'MCPAction',
'UserFeedbackAction',
]
-32
View File
@@ -1,32 +0,0 @@
from dataclasses import dataclass
from typing import Literal, Optional
from openhands.core.schema import ActionType
from openhands.events.action.action import Action
@dataclass
class UserFeedbackAction(Action):
"""An action where the user provides feedback on a message or the entire trajectory.
Attributes:
feedback_type (str): The type of feedback, either "positive" or "negative".
target_type (str): The target of the feedback, either "message" or "trajectory".
target_id (Optional[int]): The ID of the target message, if target_type is "message".
rating (Optional[int]): A numeric rating from 1-5 for the feedback (used in SAAS mode).
reason (Optional[str]): A reason for the feedback (used in SAAS mode).
action (str): The action type, namely ActionType.USER_FEEDBACK.
"""
feedback_type: Literal["positive", "negative"]
target_type: Literal["message", "trajectory"]
target_id: Optional[int] = None
rating: Optional[int] = None
reason: Optional[str] = None
action: str = ActionType.USER_FEEDBACK
@property
def message(self) -> str:
if self.target_type == "message":
return f"User provided {self.feedback_type} feedback for message {self.target_id}"
return f"User provided {self.feedback_type} feedback for the trajectory"
+12 -3
View File
@@ -186,9 +186,18 @@ class EventStream(EventStore):
if event.id is not None:
# Write the event to the store - this can take some time
self.file_store.write(
self._get_filename_for_id(event.id, self.user_id), json.dumps(data)
)
event_json = json.dumps(data)
filename = self._get_filename_for_id(event.id, self.user_id)
if len(event_json) > 1_000_000: # Roughly 1MB in bytes, ignoring encoding
logger.warning(
f'Saving event JSON over 1MB: {len(event_json):,} bytes, filename: {filename}',
extra={
'user_id': self.user_id,
'session_id': self.sid,
'size': len(event_json),
},
)
self.file_store.write(filename, event_json)
# Store the cache page last - if it is not present during reads then it will simply be bypassed.
self._store_cache_page(current_write_page)
+59
View File
@@ -22,6 +22,7 @@ from openhands.llm.tool_names import (
BROWSER_TOOL_NAME,
EXECUTE_BASH_TOOL_NAME,
FINISH_TOOL_NAME,
LLM_BASED_EDIT_TOOL_NAME,
STR_REPLACE_EDITOR_TOOL_NAME,
)
@@ -251,6 +252,58 @@ noop(1000) # Wait for page to load
USER: EXECUTION RESULT of [browser]:
[Browser shows the numbers in a table format]
"""
},
'edit_file': {
'create_file': """
ASSISTANT: There is no `app.py` file in the current directory. Let me create a Python file `app.py`:
<function=edit_file>
<parameter=path>/workspace/app.py</parameter>
<parameter=start>1</parameter>
<parameter=end>-1</parameter>
<parameter=content>
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
numbers = list(range(1, 11))
return str(numbers)
if __name__ == '__main__':
app.run(port=5000)
</parameter>
</function>
USER: EXECUTION RESULT of [edit_file]:
File created successfully at: /workspace/app.py
""",
'edit_file': """
ASSISTANT:
Now let me display the numbers in a table format:
<function=edit_file>
<parameter=path>/workspace/app.py</parameter>
<parameter=start>6</parameter>
<parameter=end>9</parameter>
<parameter=content>
numbers = list(range(1, 11))
return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
# ... existing code ...
if __name__ == '__main__':
</parameter>
</function>
USER: EXECUTION RESULT of [edit_file]:
The file /workspace/app.py has been edited. Here's the result of running `cat -n` on a snippet of /workspace/app.py:
3
4 @app.route('/')
5 def index():
6 numbers = list(range(1, 11))
7 return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
8
9 if __name__ == '__main__':
10 app.run(port=5000)
Review the changes and make sure they are as expected. Edit the file again if necessary.
""",
},
'finish': {
'task_completed': """
@@ -279,6 +332,8 @@ def get_example_for_tools(tools: list[dict]) -> str:
available_tools.add('browser')
elif name == FINISH_TOOL_NAME:
available_tools.add('finish')
elif name == LLM_BASED_EDIT_TOOL_NAME:
available_tools.add('edit_file')
if not available_tools:
return ''
@@ -297,6 +352,8 @@ USER: Create a list of numbers from 1 to 10, and display them in a web page at p
if 'str_replace_editor' in available_tools:
example += TOOL_EXAMPLES['str_replace_editor']['create_file']
elif 'edit_file' in available_tools:
example += TOOL_EXAMPLES['edit_file']['create_file']
if 'execute_bash' in available_tools:
example += TOOL_EXAMPLES['execute_bash']['run_server']
@@ -309,6 +366,8 @@ USER: Create a list of numbers from 1 to 10, and display them in a web page at p
if 'str_replace_editor' in available_tools:
example += TOOL_EXAMPLES['str_replace_editor']['edit_file']
elif 'edit_file' in available_tools:
example += TOOL_EXAMPLES['edit_file']['edit_file']
if 'execute_bash' in available_tools:
example += TOOL_EXAMPLES['execute_bash']['run_server_again']
+1
View File
@@ -4,3 +4,4 @@ EXECUTE_BASH_TOOL_NAME = 'execute_bash'
STR_REPLACE_EDITOR_TOOL_NAME = 'str_replace_editor'
BROWSER_TOOL_NAME = 'browser'
FINISH_TOOL_NAME = 'finish'
LLM_BASED_EDIT_TOOL_NAME = 'edit_file'
@@ -307,8 +307,10 @@ class LocalRuntime(ActionExecutionClient):
env['PATH'] = f'{python_bin_path}{os.pathsep}{env.get("PATH", "")}'
logger.debug(f'Updated PATH for subprocesses: {env["PATH"]}')
# Check dependencies using the derived env_root_path
check_dependencies(code_repo_path, env_root_path)
# Check dependencies using the derived env_root_path if not skipped
if os.getenv('SKIP_DEPENDENCY_CHECK', '') != '1':
check_dependencies(code_repo_path, env_root_path)
self.server_process = subprocess.Popen( # noqa: S603
cmd,
stdout=subprocess.PIPE,
@@ -406,8 +408,8 @@ class LocalRuntime(ActionExecutionClient):
return port
@tenacity.retry(
wait=tenacity.wait_exponential(min=1, max=10),
stop=tenacity.stop_after_attempt(10) | stop_if_should_exit(),
wait=tenacity.wait_fixed(2),
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
before_sleep=lambda retry_state: logger.debug(
f'Waiting for server to be ready... (attempt {retry_state.attempt_number})'
),
+17 -26
View File
@@ -4,7 +4,7 @@ import tempfile
from abc import ABC, abstractmethod
from typing import Any
from openhands_aci.utils.diff import get_diff
from openhands_aci.utils.diff import get_diff # type: ignore
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
@@ -26,39 +26,31 @@ from openhands.llm.llm import LLM
from openhands.llm.metrics import Metrics
from openhands.utils.chunk_localizer import Chunk, get_top_k_chunk_matches
SYS_MSG = """Your job is to produce a new version of the file based on the old version and the
provided draft of the new version. The provided draft may be incomplete (it may skip lines) and/or incorrectly indented. You should try to apply the changes present in the draft to the old version, and output a new version of the file.
NOTE:
- The output file should be COMPLETE and CORRECTLY INDENTED. Do not omit any lines, and do not change any lines that are not part of the changes.
- You should output the new version of the file by wrapping the new version of the file content in a ``` block.
- If there's no explicit comment to remove the existing code, we should keep them and append the new code to the end of the file.
- If there's placeholder comments like `# no changes before` or `# no changes here`, we should replace these comments with the original code near the placeholder comments.
"""
USER_MSG = """
HERE IS THE OLD VERSION OF THE FILE:
```
{old_contents}
```
Code changes will be provided in the form of a draft. You will need to apply the draft to the original code.
The original code will be enclosed within `<original_code>` tags.
The draft will be enclosed within `<update_snippet>` tags.
You need to output the update code within `<updated_code>` tags.
HERE IS THE DRAFT OF THE NEW VERSION OF THE FILE:
```
{draft_changes}
```
Within the `<updated_code>` tag, include only the final code after updation. Do not include any explanations or other content within these tags.
GIVE ME THE NEW VERSION OF THE FILE.
IMPORTANT:
- There should be NO placeholder comments like `# no changes before` or `# no changes here`. They should be replaced with the original code near the placeholder comments.
- The output file should be COMPLETE and CORRECTLY INDENTED. Do not omit any lines, and do not change any lines that are not part of the changes.
""".strip()
<original_code>{old_contents}</original_code>
<update_snippet>{draft_changes}</update_snippet>
"""
def _extract_code(string: str) -> str | None:
pattern = r'```(?:\w*\n)?(.*?)```'
pattern = r'<updated_code>(.*?)</updated_code>'
matches = re.findall(pattern, string, re.DOTALL)
if not matches:
return None
return str(matches[0])
content = str(matches[0])
if content.startswith('#EDIT:'):
#Remove first line
content = content[content.find('\n') + 1:]
return content
def get_new_file_contents(
@@ -66,7 +58,6 @@ def get_new_file_contents(
) -> str | None:
while num_retries > 0:
messages = [
{'role': 'system', 'content': SYS_MSG},
{
'role': 'user',
'content': USER_MSG.format(
+1 -1
View File
@@ -38,7 +38,7 @@ def send_request(
session: HttpSession,
method: str,
url: str,
timeout: int = 10,
timeout: int = 60,
**kwargs: Any,
) -> httpx.Response:
response = session.request(method, url, timeout=timeout, **kwargs)
-2
View File
@@ -15,7 +15,6 @@ from fastapi import (
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
from openhands.server.routes.git import app as git_api_router
from openhands.server.routes.health import add_health_endpoints
@@ -63,7 +62,6 @@ app = FastAPI(
app.include_router(public_api_router)
app.include_router(files_api_router)
app.include_router(security_api_router)
app.include_router(feedback_api_router)
app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
@@ -22,7 +22,6 @@ from openhands.events.nested_event_store import NestedEventStore
from openhands.events.stream import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.llm.llm import LLM
from openhands.runtime.impl.docker.containers import stop_all_containers
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
from openhands.server.config.server_config import ServerConfig
from openhands.server.conversation_manager.conversation_manager import (
@@ -90,7 +89,7 @@ class DockerNestedConversationManager(ConversationManager):
"""
Get the running agent loops directly from docker.
"""
containers : list[Container] = self.docker_client.containers.list()
containers: list[Container] = self.docker_client.containers.list()
names = (container.name or '' for container in containers)
conversation_ids = {
name[len('openhands-runtime-') :]
@@ -284,7 +283,7 @@ class DockerNestedConversationManager(ConversationManager):
# First try to graceful stop server.
try:
container = self.docker_client.containers.get(f'openhands-runtime-{sid}')
except docker.errors.NotFound as e:
except docker.errors.NotFound:
return
try:
nested_url = self.get_nested_url_for_container(container)
@@ -293,25 +292,33 @@ class DockerNestedConversationManager(ConversationManager):
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
}
) as client:
response = await client.post(f'{nested_url}/api/conversations/{sid}/stop')
# Stop conversation
response = await client.post(
f'{nested_url}/api/conversations/{sid}/stop'
)
response.raise_for_status()
# Check up to 3 times that client has closed
for _ in range(3):
response = await client.get(f'{nested_url}/api/conversations/{sid}')
if response.status_code == status.HTTP_200_OK and response.json().get('status') == "STOPPED":
response.raise_for_status()
if response.json().get('status') == 'STOPPED':
break
await asyncio.sleep(1)
except Exception:
logger.exception("error_stopping_container")
except Exception as e:
logger.warning('error_stopping_container', extra={"sid": sid, "error": str(e)})
container.stop()
async def get_agent_loop_info(self, user_id: str | None = None, filter_to_sids: set[str] | None = None) -> list[AgentLoopInfo]:
async def get_agent_loop_info(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> list[AgentLoopInfo]:
results = []
containers : list[Container] = self.docker_client.containers.list()
containers: list[Container] = self.docker_client.containers.list()
for container in containers:
if not container.name or not container.name.startswith('openhands-runtime-'):
if not container.name or not container.name.startswith(
'openhands-runtime-'
):
continue
conversation_id = container.name[len('openhands-runtime-') :]
if filter_to_sids is not None and conversation_id not in filter_to_sids:
@@ -389,7 +396,9 @@ class DockerNestedConversationManager(ConversationManager):
)
return session_api_key
async def ensure_num_conversations_below_limit(self, sid: str, user_id: str | None) -> None:
async def ensure_num_conversations_below_limit(
self, sid: str, user_id: str | None
) -> None:
response_ids = await self.get_running_agent_loops(user_id)
if len(response_ids) >= self.config.max_concurrent_conversations:
logger.info(
@@ -431,7 +440,9 @@ class DockerNestedConversationManager(ConversationManager):
)
return provider_handler
async def _create_runtime(self, sid: str, user_id: str | None, settings: Settings) -> DockerRuntime:
async def _create_runtime(
self, sid: str, user_id: str | None, settings: Settings
) -> DockerRuntime:
# This session is created here only because it is the easiest way to get a runtime, which
# is the easiest way to create the needed docker container
session = Session(
@@ -463,8 +474,9 @@ class DockerNestedConversationManager(ConversationManager):
env_vars['SESSION_API_KEY'] = self._get_session_api_key_for_conversation(sid)
# We need to be able to specify the nested conversation id within the nested runtime
env_vars['ALLOW_SET_CONVERSATION_ID'] = '1'
env_vars['WORKSPACE_BASE'] = f'/workspace'
env_vars['WORKSPACE_BASE'] = '/workspace'
env_vars['SANDBOX_CLOSE_DELAY'] = '0'
env_vars['SKIP_DEPENDENCY_CHECK'] = '1'
# Set up mounted volume for conversation directory within workspace
# TODO: Check if we are using the standard event store and file store
@@ -509,7 +521,7 @@ class DockerNestedConversationManager(ConversationManager):
await call_sync_from_async(container.start)
return True
return False
except docker.errors.NotFound as e:
except docker.errors.NotFound:
return False
-45
View File
@@ -1,45 +0,0 @@
import json
from typing import Any, Literal
import httpx
from pydantic import BaseModel
from openhands.core.logger import openhands_logger as logger
class FeedbackDataModel(BaseModel):
version: str
email: str
polarity: Literal['positive', 'negative']
feedback: Literal[
'positive', 'negative'
] # TODO: remove this, its here for backward compatibility
permissions: Literal['public', 'private']
trajectory: list[dict[str, Any]] | None
FEEDBACK_URL = 'https://share-od-trajectory-3u9bw9tx.uc.gateway.dev/share_od_trajectory'
def store_feedback(feedback: FeedbackDataModel) -> dict[str, str]:
# Start logging
feedback.feedback = feedback.polarity
display_feedback = feedback.model_dump()
if 'trajectory' in display_feedback:
display_feedback['trajectory'] = (
f'elided [length: {len(display_feedback["trajectory"])}'
)
if 'token' in display_feedback:
display_feedback['token'] = 'elided'
logger.debug(f'Got feedback: {display_feedback}')
# Start actual request
response = httpx.post(
FEEDBACK_URL,
headers={'Content-Type': 'application/json'},
json=feedback.model_dump(),
)
if response.status_code != 200:
raise ValueError(f'Failed to store feedback: {response.text}')
response_data: dict[str, str] = json.loads(response.text)
logger.debug(f'Stored feedback: {response.text}')
return response_data
-62
View File
@@ -1,62 +0,0 @@
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
from openhands.events.serialization import event_to_dict
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
from openhands.server.dependencies import get_dependencies
from openhands.server.utils import get_conversation
from openhands.utils.async_utils import call_sync_from_async
from openhands.server.session.conversation import ServerConversation
app = APIRouter(prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies())
@app.post('/submit-feedback')
async def submit_feedback(request: Request, conversation: ServerConversation = Depends(get_conversation)) -> JSONResponse:
"""Submit user feedback.
This function stores the provided feedback data.
To submit feedback:
```sh
curl -X POST -d '{"email": "test@example.com"}' -H "Authorization:"
```
Args:
request (Request): The incoming request object.
feedback (FeedbackDataModel): The feedback data to be stored.
Returns:
dict: The stored feedback data.
Raises:
HTTPException: If there's an error submitting the feedback.
"""
# Assuming the storage service is already configured in the backend
# and there is a function to handle the storage.
body = await request.json()
async_store = AsyncEventStoreWrapper(
conversation.event_stream, filter_hidden=True
)
trajectory = []
async for event in async_store:
trajectory.append(event_to_dict(event))
feedback = FeedbackDataModel(
email=body.get('email', ''),
version=body.get('version', ''),
permissions=body.get('permissions', 'private'),
polarity=body.get('polarity', ''),
feedback=body.get('polarity', ''),
trajectory=trajectory,
)
try:
feedback_data = await call_sync_from_async(store_feedback, feedback)
return JSONResponse(status_code=status.HTTP_200_OK, content=feedback_data)
except Exception as e:
logger.error(f'Error submitting feedback: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Failed to submit feedback'},
)
Generated
+303 -251
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -36,7 +36,7 @@ numpy = "*"
json-repair = "*"
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
html2text = "*"
e2b = ">=1.0.5,<1.4.0"
e2b = ">=1.0.5,<1.6.0"
pexpect = "*"
jinja2 = "^3.1.3"
python-multipart = "*"
@@ -53,7 +53,7 @@ protobuf = "^5.0.0,<6.0.0" # Updated to support newer op
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
modal = ">=0.66.26,<0.78.0"
runloop-api-client = "0.33.0"
runloop-api-client = "0.39.0"
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
joblib = "*"
@@ -80,7 +80,7 @@ bashlex = "^0.18"
# TODO: These are integrations that should probably be optional
redis = ">=5.2,<7.0"
minio = "^7.2.8"
daytona-sdk = "0.18.1"
daytona-sdk = "0.20.0"
stripe = ">=11.5,<13.0"
google-cloud-aiplatform = "*"
anthropic = { extras = [ "vertex" ], version = "*" }
@@ -90,8 +90,8 @@ boto3 = "*"
optional = true
[tool.poetry.group.dev.dependencies]
ruff = "0.11.11"
mypy = "1.15.0"
ruff = "0.11.13"
mypy = "1.16.0"
pre-commit = "4.2.0"
build = "*"
types-setuptools = "*"