mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
29 Commits
add-cli-ll
...
feat/mcp-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82019cfbf3 | ||
|
|
8ccedf5990 | ||
|
|
ec355bf962 | ||
|
|
91e6d359c2 | ||
|
|
a9f26a13a6 | ||
|
|
a92d6904fc | ||
|
|
306777626f | ||
|
|
1807efad0b | ||
|
|
e074b2d36f | ||
|
|
b7efeb11d9 | ||
|
|
7d0aadf8ed | ||
|
|
78af1de870 | ||
|
|
6a9065960d | ||
|
|
653a8a7ce2 | ||
|
|
3591c7a79f | ||
|
|
bae6bd77f4 | ||
|
|
30c71776e7 | ||
|
|
147ffb7e42 | ||
|
|
237037cee9 | ||
|
|
567af43a71 | ||
|
|
65071550b6 | ||
|
|
d81d2f62cb | ||
|
|
ddaa186971 | ||
|
|
e6e0f4673f | ||
|
|
7d78b65a1a | ||
|
|
1f90086030 | ||
|
|
2c4ecd02f7 | ||
|
|
2fd1fdcd7e | ||
|
|
cbe32a1a12 |
@@ -1,5 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mark the current repository as safe for Git to prevent "dubious ownership" errors,
|
||||
# which can occur in containerized environments when directory ownership doesn't match the current user.
|
||||
git config --global --add safe.directory "$(realpath .)"
|
||||
|
||||
# Install `nc`
|
||||
sudo apt update && sudo apt install netcat -y
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ This repository contains the code for OpenHands, an automated AI software engine
|
||||
To set up the entire repo, including frontend and backend, run `make build`.
|
||||
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
|
||||
|
||||
## Running OpenHands with OpenHands:
|
||||
To run the full application to debug issues:
|
||||
```bash
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 &> /tmp/openhands-log.txt &
|
||||
```
|
||||
|
||||
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
|
||||
|
||||
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
|
||||
|
||||
@@ -103,6 +103,29 @@ components or interface enhancements.
|
||||
make start-frontend
|
||||
```
|
||||
|
||||
### 5. Running OpenHands with OpenHands
|
||||
|
||||
You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
1. **Build and run OpenHands:**
|
||||
```bash
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run
|
||||
```
|
||||
|
||||
2. **Access the interface:**
|
||||
- Local development: http://localhost:3001
|
||||
- Remote/cloud environments: Use the appropriate external URL
|
||||
|
||||
3. **Configure for external access (if needed):**
|
||||
```bash
|
||||
# For external access (e.g., cloud environments)
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
|
||||
### 6. LLM Debugging
|
||||
|
||||
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
|
||||
@@ -136,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.43-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.44-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
91
MCP_CLI_RUNTIME_IMPLEMENTATION_SUMMARY.md
Normal file
91
MCP_CLI_RUNTIME_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# MCP CLI Runtime Implementation Summary
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
✅ **Phase 1: HTTP/SSE Support** - Successfully implemented MCP action support in CLI Runtime with maximum code reuse from existing infrastructure.
|
||||
|
||||
### Key Features Implemented
|
||||
|
||||
1. **MCP Action Execution**: `call_tool_mcp()` method that handles MCP actions
|
||||
2. **Configuration Management**: `get_mcp_config()` method that loads MCP config from multiple sources
|
||||
3. **Error Handling**: Proper Windows platform checks and error reporting
|
||||
4. **Code Reuse**: ~80% code reuse from `action_execution_client.py` patterns
|
||||
|
||||
### Configuration Sources (in order of precedence)
|
||||
|
||||
1. **OpenHands Config**: If your OpenHands config already has MCP settings
|
||||
2. **Environment Variables**: For programmatic configuration
|
||||
3. **User Config File**: `~/.openhands/config.toml` (completely optional)
|
||||
4. **Default Empty Config**: If no configuration is found
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
- **Reused Infrastructure**: Uses existing `MCPClient`, `create_mcp_clients`, `call_tool_mcp` from utils
|
||||
- **Consistent Patterns**: Same error handling, logging, and platform checks as other runtimes
|
||||
- **TOML Loading**: Uses OpenHands standard `toml` library and `MCPConfig.from_toml_section()`
|
||||
- **No Dependencies**: No new dependencies added
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### User Config File (`~/.openhands/config.toml`)
|
||||
```toml
|
||||
[mcp]
|
||||
# SSE Servers - External servers that communicate via Server-Sent Events
|
||||
sse_servers = [
|
||||
# Basic SSE server with just a URL
|
||||
"http://localhost:3000/mcp",
|
||||
|
||||
# SSE server with API key authentication
|
||||
{url="https://secure-example.com/mcp", api_key="your-api-key"}
|
||||
]
|
||||
|
||||
# Note: stdio_servers are not yet supported in CLI Runtime (Phase 2)
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
export OPENHANDS_MCP_SSE_SERVERS='[{"url":"http://localhost:3000/mcp"}]'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from openhands.runtime.impl.cli import CLIRuntime
|
||||
from openhands.events.action import MCPAction
|
||||
|
||||
# Create runtime
|
||||
runtime = CLIRuntime(config=your_config)
|
||||
|
||||
# Execute MCP action
|
||||
action = MCPAction(server_name="your-server", tool_name="your-tool", arguments={})
|
||||
result = await runtime.call_tool_mcp(action)
|
||||
```
|
||||
|
||||
## What's Next (Phase 2)
|
||||
|
||||
- **Stdio MCP Client Implementation**: Support for local process-based MCP servers
|
||||
- **Process Management**: Handle stdio server lifecycle
|
||||
- **Enhanced Configuration**: Auto-discovery of localhost MCP servers
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ✅ **Backward Compatible**: Existing CLI runtime functionality unchanged
|
||||
- ✅ **Cross-Platform**: Works on Windows, macOS, Linux (Windows has MCP disabled)
|
||||
- ✅ **Optional Config**: Works without any configuration files
|
||||
- ✅ **Docker Alternative**: Provides MCP support without Docker requirements
|
||||
|
||||
## Code Quality
|
||||
|
||||
- ✅ **High Code Reuse**: ~80% reuse from existing action_execution_client.py
|
||||
- ✅ **Consistent Error Handling**: Same patterns as other runtimes
|
||||
- ✅ **Proper Validation**: Uses existing MCPConfig validation
|
||||
- ✅ **Clean Implementation**: Minimal changes, focused functionality
|
||||
|
||||
## Testing
|
||||
|
||||
The implementation has been validated for:
|
||||
- ✅ Proper import structure
|
||||
- ✅ Code reuse patterns
|
||||
- ✅ Error handling
|
||||
- ✅ Configuration loading
|
||||
- ✅ Phase 1 requirements compliance
|
||||
10
README.md
10
README.md
@@ -62,19 +62,21 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.43
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
When you open the application, you'll be asked to choose an LLM provider and add an API key.
|
||||
|
||||
10
README_CN.md
10
README_CN.md
@@ -51,19 +51,21 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.43
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
您将在[http://localhost:3000](http://localhost:3000)找到运行中的OpenHands!
|
||||
|
||||
打开应用程序时,您将被要求选择一个LLM提供商并添加API密钥。
|
||||
|
||||
@@ -44,7 +44,7 @@ ENV WORKSPACE_BASE=/opt/workspace_base
|
||||
ENV OPENHANDS_BUILD_VERSION=$OPENHANDS_BUILD_VERSION
|
||||
ENV SANDBOX_USER_ID=0
|
||||
ENV FILE_STORE=local
|
||||
ENV FILE_STORE_PATH=/.openhands-state
|
||||
ENV FILE_STORE_PATH=/.openhands
|
||||
RUN mkdir -p $FILE_STORE_PATH
|
||||
RUN mkdir -p $WORKSPACE_BASE
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.43-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.44-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,8 +7,8 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -16,7 +16,7 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ~/.openhands-state:/.openhands-state
|
||||
- ~/.openhands:/.openhands
|
||||
- ${WORKSPACE_BASE:-$PWD/workspace}:/opt/workspace_base
|
||||
pull_policy: build
|
||||
stdin_open: true
|
||||
|
||||
@@ -35,7 +35,7 @@ You can grant OpenHands access to specific GitHub repositories:
|
||||
|
||||
You can modify GitHub repository access at any time by:
|
||||
- Selecting `Add GitHub repos` on the landing page or
|
||||
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab
|
||||
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Integrations` tab
|
||||
|
||||
## Working With GitHub Repos in Openhands Cloud
|
||||
|
||||
|
||||
@@ -5,15 +5,38 @@ description: This guide walks you through installing the OpenHands Slack app.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You are a slack workspace admin
|
||||
- Access to OpenHands Cloud
|
||||
|
||||
## Installation Steps
|
||||
|
||||
1. Log in to [OpenHands Cloud](https://app.all-hands.dev)
|
||||
2. Click the button below to OpenHands Slack App <a target="_blank" href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,chat:write,users:read,channels:history,groups:history,mpim:history,im:history&user_scope=channels:history,groups:history,im:history,mpim:history"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
|
||||
3. In the top right corner, select the workspace to install the OpenHands Slack app.
|
||||
4. Review permissions and click allow
|
||||
<AccordionGroup>
|
||||
<Accordion title="Install Slack App (only for Slack admins/owners)">
|
||||
|
||||
**This step is for Slack admins/owners**
|
||||
|
||||
1. Make sure you have permissions to install Apps to your workspace.
|
||||
2. Click the button below to install OpenHands Slack App <a target="_blank" href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,chat:write,users:read,channels:history,groups:history,mpim:history,im:history&user_scope=channels:history,groups:history,im:history,mpim:history"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
|
||||
3. In the top right corner, select the workspace to install the OpenHands Slack app.
|
||||
4. Review permissions and click allow.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Authorize Slack App (for all Slack workspace members)">
|
||||
|
||||
**Make sure your Slack workspace admin/owner has installed OpenHands Slack App first**
|
||||
|
||||
Every user in the Slack workspace (including admins/owners) must link their Cloud OpenHands account to the OpenHands Slack App. To do this:
|
||||
1. Visit [integrations settings](https://app.all-hands.dev/settings/integrations) in OpenHands Cloud.
|
||||
2. Click the button "Install Slack App".
|
||||
3. In the top right corner, select the workspace to install the OpenHands Slack app.
|
||||
4. Review permissions and click allow.
|
||||
|
||||
Depending on the workspace settings, you may need approval from your Slack admin to authorize the Slack App.
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
## Working With the Slack App
|
||||
|
||||
@@ -45,6 +68,6 @@ You can mention a repo name when starting a new conversation in the following fo
|
||||
2. "All-Hands-AI/OpenHands" (e.g `@openhands in All-Hands-AI/OpenHands ...`)
|
||||
|
||||
The repo match is case insensitive. If a repo name match is made, it will kick off the conversation.
|
||||
If the repo name partially matches against, multiple repos, you'll be asked to select a repo from the filtered list.
|
||||
If the repo name partially matches against multiple repos, you'll be asked to select a repo from the filtered list.
|
||||
|
||||

|
||||
|
||||
@@ -11,10 +11,18 @@ for scripting.
|
||||
|
||||
### Running with Python
|
||||
|
||||
**Note** - OpenHands requires Python version 3.12 or higher (Python 3.14 is not currently supported)
|
||||
|
||||
1. Install OpenHands using pip:
|
||||
|
||||
```bash
|
||||
pip install openhands-ai
|
||||
```
|
||||
|
||||
Or if you prefer not to manage your own Python environment, you can use `uvx`:
|
||||
|
||||
```bash
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
```
|
||||
|
||||
2. Launch an interactive OpenHands conversation from the command line:
|
||||
@@ -47,19 +55,21 @@ poetry run python -m openhands.cli.main
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
-e LLM_MODEL=$LLM_MODEL \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.43 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
This launches the CLI in Docker, allowing you to interact with OpenHands as described above.
|
||||
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` ensures files created by the agent in your workspace have the correct permissions.
|
||||
|
||||
@@ -45,7 +45,7 @@ OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if pro
|
||||
- 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)
|
||||
2. **Enter Token in OpenHands**:
|
||||
- In the Settings page, navigate to the `Git` tab.
|
||||
- In the Settings page, navigate to the `Integrations` tab.
|
||||
- Paste your token in the `GitHub Token` field.
|
||||
- Click `Save Changes` to apply the changes.
|
||||
|
||||
@@ -97,7 +97,7 @@ 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**:
|
||||
- In the Settings page, navigate to the `Git` tab.
|
||||
- In the Settings page, navigate to the `Integrations` tab.
|
||||
- Paste your token in the `GitLab Token` field.
|
||||
- Click `Save Changes` to apply the changes.
|
||||
|
||||
@@ -122,6 +122,42 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
#### BitBucket Setup (Coming soon ...)
|
||||
<AccordionGroup>
|
||||
<Accordion title="Setting Up a BitBucket Password">
|
||||
1. **Generate an App Password**:
|
||||
- On BitBucket, go to Personal Settings > App Password.
|
||||
- Create a new password with the following scopes:
|
||||
- `repository: read`
|
||||
- `repository: write`
|
||||
- `pull requests: read`
|
||||
- `pull requests: write`
|
||||
- `issues: read`
|
||||
- `issues: write`
|
||||
- App passwords are non-expiring token. OpenHands will migrate to using API tokens in the future.
|
||||
2. **Enter Token in OpenHands**:
|
||||
- In the Settings page, navigate to the `Integrations` tab.
|
||||
- Paste your token in the `BitBucket Token` field.
|
||||
- Click `Save Changes` to apply the changes.
|
||||
</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.
|
||||
|
||||
- **Verifying Token Works**:
|
||||
- The app will show a green checkmark if the token is valid.
|
||||
- Try accessing a repository to confirm permissions.
|
||||
- Check the browser console for any error messages.
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
#### Secrets Management
|
||||
|
||||
OpenHands provides a secrets manager that allows you to securely store and manage sensitive information that can be accessed by the agent during runtime, such as API keys. These secrets are automatically exported as environment variables in the agent's runtime environment.
|
||||
|
||||
@@ -32,19 +32,20 @@ To run OpenHands in Headless mode with Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
-e LLM_MODEL=$LLM_MODEL \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.43 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host user’s
|
||||
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
|
||||
|
||||
@@ -54,25 +54,27 @@ Check [the installation guide](/usage/local-setup) to make sure you have all the
|
||||
export LMSTUDIO_MODEL_NAME="imported-models/uncategorized/devstralq4_k_m.gguf" # <- Replace this with the model name you copied from LMStudio
|
||||
export LMSTUDIO_URL="http://host.docker.internal:1234" # <- Replace this with the port from LMStudio
|
||||
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik
|
||||
|
||||
mkdir -p ~/.openhands-state && echo '{"language":"en","agent":"CodeActAgent","max_iterations":null,"security_analyzer":null,"confirmation_mode":false,"llm_model":"lm_studio/'$LMSTUDIO_MODEL_NAME'","llm_api_key":"dummy","llm_base_url":"'$LMSTUDIO_URL/v1'","remote_runtime_resource_factor":null,"github_token":null,"enable_default_condenser":true,"user_consents_to_analytics":true}' > ~/.openhands-state/settings.json
|
||||
mkdir -p ~/.openhands && echo '{"language":"en","agent":"CodeActAgent","max_iterations":null,"security_analyzer":null,"confirmation_mode":false,"llm_model":"lm_studio/'$LMSTUDIO_MODEL_NAME'","llm_api_key":"dummy","llm_base_url":"'$LMSTUDIO_URL/v1'","remote_runtime_resource_factor":null,"github_token":null,"enable_default_condenser":true,"user_consents_to_analytics":true}' > ~/.openhands/settings.json
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.43
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
Once your server is running -- you can visit `http://localhost:3000` in your browser to use OpenHands with local Devstral model:
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.43
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
@@ -126,6 +128,18 @@ vllm serve all-hands/openhands-lm-32b-v0.1 \
|
||||
--enable-prefix-caching
|
||||
```
|
||||
|
||||
### Create an OpenAI-Compatible Endpoint with Ollama
|
||||
|
||||
- Install Ollama following [the official documentation](https://ollama.com/download).
|
||||
- For Ollama configuration, use `ollama/<modelname>` as custom model in web. Api key also can be set to `ollama`.
|
||||
- Example launch command for Devstral LM 24B:
|
||||
|
||||
```bash
|
||||
OLLAMA_CONTEXT_LENGTH=32768 OLLAMA_HOST=0.0.0.0:11434 OLLAMA_KEEP_ALIVE=-1 nohup ollama serve&
|
||||
#The minimum context size is ~8196, even the system prompt won't fit smaller
|
||||
ollama pull devstral:latest
|
||||
```
|
||||
|
||||
## Advanced: Run and Configure OpenHands
|
||||
|
||||
### Run OpenHands
|
||||
|
||||
@@ -67,19 +67,21 @@ 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.43-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.43-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.43
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -31,9 +31,9 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
|
||||
|
||||
**Resolution**
|
||||
|
||||
* Check if the `~/.openhands-state` is owned by `root`. If so, you can:
|
||||
* Change the directory's ownership: `sudo chown <user>:<user> ~/.openhands-state`.
|
||||
* or update permissions on the directory: `sudo chmod 777 ~/.openhands-state`
|
||||
* Check if the `~/.openhands` is owned by `root`. If so, you can:
|
||||
* Change the directory's ownership: `sudo chown <user>:<user> ~/.openhands`.
|
||||
* or update permissions on the directory: `sudo chmod 777 ~/.openhands`
|
||||
* or delete it if you don’t need previous data. OpenHands will recreate it. You'll need to re-enter LLM settings.
|
||||
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
|
||||
OpenHands.
|
||||
@@ -56,13 +56,16 @@ To fix this:
|
||||
-e SANDBOX_VSCODE_PORT=41234 \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:latest \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
-p 41234:41234 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:latest
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
2. Make sure to expose the same port with `-p 41234:41234` in your Docker command.
|
||||
3. If running with the development workflow, you can set this in your `config.toml` file:
|
||||
```toml
|
||||
|
||||
1
evaluation/benchmarks/gaia/.gitignore
vendored
Normal file
1
evaluation/benchmarks/gaia/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data/
|
||||
@@ -6,6 +6,13 @@ This folder contains evaluation harness for evaluating agents on the [GAIA bench
|
||||
|
||||
Please follow instruction [here](../../README.md#setup) to setup your local development environment and LLM.
|
||||
|
||||
To enable the Tavily MCP Server, you can add the Tavily API key under the `core` section of your `config.toml` file, like below:
|
||||
|
||||
```toml
|
||||
[core]
|
||||
search_api_key = "tvly-******"
|
||||
```
|
||||
|
||||
## Run the evaluation
|
||||
|
||||
We are using the GAIA dataset hosted on [Hugging Face](https://huggingface.co/datasets/gaia-benchmark/GAIA).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
@@ -6,6 +7,7 @@ import re
|
||||
import huggingface_hub
|
||||
import pandas as pd
|
||||
from datasets import load_dataset
|
||||
from pydantic import SecretStr
|
||||
|
||||
from evaluation.benchmarks.gaia.scorer import question_scorer
|
||||
from evaluation.utils.shared import (
|
||||
@@ -24,6 +26,7 @@ from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_from_toml,
|
||||
)
|
||||
from openhands.core.config.utils import get_agent_config_arg
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -41,7 +44,7 @@ AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
|
||||
}
|
||||
|
||||
AGENT_CLS_TO_INST_SUFFIX = {
|
||||
'CodeActAgent': 'When you think you have solved the question, please first send your answer to user through message and then exit.\n'
|
||||
'CodeActAgent': 'When you think you have solved the question, please use the finish tool and include your final answer in the message parameter of the finish tool. Your final answer MUST be encapsulated within <solution> and </solution>.\n'
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +52,7 @@ def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
sandbox_config.base_container_image = 'nikolaik/python-nodejs:python3.12-nodejs22'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
@@ -67,6 +70,11 @@ def get_config(
|
||||
logger.info('Agent config not provided, using default settings')
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
|
||||
config_copy = copy.deepcopy(config)
|
||||
load_from_toml(config_copy)
|
||||
if config_copy.search_api_key:
|
||||
config.search_api_key = SecretStr(config_copy.search_api_key)
|
||||
return config
|
||||
|
||||
|
||||
@@ -134,16 +142,26 @@ def process_instance(
|
||||
dest_file = None
|
||||
|
||||
# Prepare instruction
|
||||
instruction = f'{instance["Question"]}\n'
|
||||
instruction = """You have one question to answer. It is paramount that you provide a correct answer.
|
||||
Give it all you can: I know for a fact that you have access to all the relevant tools to solve it and find the correct answer (the answer does exist). Failure or 'I cannot answer' or 'None found' will not be tolerated, success will be rewarded.
|
||||
You must make sure you find the correct answer! You MUST strictly follow the task-specific formatting instructions for your final answer.
|
||||
Here is the task:
|
||||
{task_question}
|
||||
""".format(
|
||||
task_question=instance['Question'],
|
||||
)
|
||||
logger.info(f'Instruction: {instruction}')
|
||||
if dest_file:
|
||||
instruction += f'\n\nThe mentioned file is provided in the workspace at: {dest_file.split("/")[-1]}'
|
||||
|
||||
instruction += 'IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\n'
|
||||
instruction += 'Please encapsulate your final answer (answer ONLY) within <solution> and </solution>.\n'
|
||||
instruction += """IMPORTANT: When seeking information from a website, REFRAIN from arbitrary URL navigation. You should utilize the designated search engine tool with precise keywords to obtain relevant URLs or use the specific website's search interface. DO NOT navigate directly to specific URLs as they may not exist.\n\nFor example: if you want to search for a research paper on Arxiv, either use the search engine tool with specific keywords or navigate to arxiv.org and then use its interface.\n"""
|
||||
instruction += 'IMPORTANT: You should NEVER ask for Human Help.\n'
|
||||
instruction += 'IMPORTANT: Please encapsulate your final answer (answer ONLY) within <solution> and </solution>. Your answer will be evaluated using string matching approaches so it important that you STRICTLY adhere to the output formatting instructions specified in the task (e.g., alphabetization, sequencing, units, rounding, decimal places, etc.)\n'
|
||||
instruction += (
|
||||
'For example: The answer to the question is <solution> 42 </solution>.\n'
|
||||
)
|
||||
instruction += "IMPORTANT: Your final answer should be a number OR as few words as possible OR a comma separated list of numbers and/or strings. If you are asked for a number, express it numerically (i.e., with digits rather than words), do not use commas, and do not include units such as $ or percent signs unless specified otherwise. If you are asked for a string, don't use articles, neither abbreviations (e.g. for cities). If you are asked for a comma separated list, apply the above rules depending of whether the element to be put in the list is a number or a string.\n"
|
||||
|
||||
# NOTE: You can actually set slightly different instruction for different agents
|
||||
instruction += AGENT_CLS_TO_INST_SUFFIX.get(metadata.agent_class, '')
|
||||
logger.info(f'Instruction:\n{instruction}', extra={'msg_type': 'OBSERVATION'})
|
||||
@@ -175,7 +193,7 @@ def process_instance(
|
||||
for event in reversed(state.history):
|
||||
if event.source == 'agent':
|
||||
if isinstance(event, AgentFinishAction):
|
||||
model_answer_raw = event.thought
|
||||
model_answer_raw = event.final_thought
|
||||
break
|
||||
elif isinstance(event, CmdRunAction):
|
||||
model_answer_raw = event.thought
|
||||
@@ -222,6 +240,7 @@ def process_instance(
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
test_result=test_result,
|
||||
)
|
||||
runtime.close()
|
||||
return output
|
||||
|
||||
|
||||
@@ -253,6 +272,8 @@ if __name__ == '__main__':
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
toml_config = OpenHandsConfig()
|
||||
load_from_toml(toml_config)
|
||||
metadata = make_metadata(
|
||||
llm_config=llm_config,
|
||||
dataset_name='gaia',
|
||||
@@ -261,7 +282,10 @@ if __name__ == '__main__':
|
||||
eval_note=args.eval_note,
|
||||
eval_output_dir=args.eval_output_dir,
|
||||
data_split=args.data_split,
|
||||
details={'gaia-level': args.level},
|
||||
details={
|
||||
'gaia-level': args.level,
|
||||
'mcp-servers': ['tavily'] if toml_config.search_api_key else [],
|
||||
},
|
||||
agent_config=agent_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ echo "LEVELS: $LEVELS"
|
||||
COMMAND="poetry run python ./evaluation/benchmarks/gaia/run_infer.py \
|
||||
--agent-cls $AGENT \
|
||||
--llm-config $MODEL_CONFIG \
|
||||
--max-iterations 30 \
|
||||
--max-iterations 60 \
|
||||
--level $LEVELS \
|
||||
--data-split validation \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
|
||||
@@ -116,7 +116,7 @@ def get_token_per_line(code: str):
|
||||
return identifiers_per_line
|
||||
|
||||
|
||||
def get_ISM(answer_code: str, model_output_list: list, asnwer_name: str) -> list:
|
||||
def get_ISM(answer_code: str, model_output_list: list, answer_name: str) -> list:
|
||||
"""
|
||||
计算ISM,返回一个有序的得分列表
|
||||
:return:
|
||||
@@ -126,13 +126,13 @@ def get_ISM(answer_code: str, model_output_list: list, asnwer_name: str) -> list
|
||||
if '```python' in code:
|
||||
code = code.replace('```python', '')
|
||||
code = code.replace('```', '')
|
||||
if not re.search(rf'\b{re.escape(asnwer_name)}\b', code) or not is_code_valid(
|
||||
if not re.search(rf'\b{re.escape(answer_name)}\b', code) or not is_code_valid(
|
||||
code
|
||||
):
|
||||
score_list.append(0)
|
||||
continue
|
||||
|
||||
# if asnwer_name not in code:
|
||||
# if answer_name not in code:
|
||||
# score_list.append(0)
|
||||
# continue
|
||||
|
||||
@@ -155,7 +155,7 @@ def get_ISM(answer_code: str, model_output_list: list, asnwer_name: str) -> list
|
||||
|
||||
|
||||
def get_ISM_without_verification(
|
||||
answer_code: str, model_output_list: list, asnwer_name: str
|
||||
answer_code: str, model_output_list: list, answer_name: str
|
||||
) -> list:
|
||||
"""
|
||||
计算ISM,返回一个有序的得分列表
|
||||
@@ -163,11 +163,11 @@ def get_ISM_without_verification(
|
||||
"""
|
||||
score_list = []
|
||||
for code in model_output_list:
|
||||
if asnwer_name not in code:
|
||||
if answer_name not in code:
|
||||
score_list.append(0)
|
||||
continue
|
||||
|
||||
# if asnwer_name not in code:
|
||||
# if answer_name not in code:
|
||||
# score_list.append(0)
|
||||
# continue
|
||||
|
||||
@@ -215,7 +215,7 @@ def longest_common_prefix_with_lengths(list1, list2):
|
||||
return max_length, len_list1, len_list2
|
||||
|
||||
|
||||
def get_PM(answer_code: str, model_output_list: list, asnwer_name: str) -> list:
|
||||
def get_PM(answer_code: str, model_output_list: list, answer_name: str) -> list:
|
||||
"""
|
||||
计算PM,返回一个有序的得分列表
|
||||
:return:
|
||||
@@ -225,14 +225,14 @@ def get_PM(answer_code: str, model_output_list: list, asnwer_name: str) -> list:
|
||||
if '```python' in code:
|
||||
code = code.replace('```python', '')
|
||||
code = code.replace('```', '')
|
||||
if not re.search(rf'\b{re.escape(asnwer_name)}\b', code) or not is_code_valid(
|
||||
if not re.search(rf'\b{re.escape(answer_name)}\b', code) or not is_code_valid(
|
||||
code
|
||||
):
|
||||
# if asnwer_name not in code or is_code_valid(code) == False:
|
||||
# if answer_name not in code or is_code_valid(code) == False:
|
||||
score_list.append(0)
|
||||
continue
|
||||
|
||||
# if asnwer_name not in code:
|
||||
# if answer_name not in code:
|
||||
# score_list.append(0)
|
||||
# continue
|
||||
|
||||
|
||||
@@ -193,9 +193,9 @@ describe("ChatInput", () => {
|
||||
|
||||
it("should handle image paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onImagePaste = vi.fn();
|
||||
const onFilesPaste = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
|
||||
render(<ChatInput onSubmit={onSubmit} onFilesPaste={onFilesPaste} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
@@ -213,8 +213,8 @@ describe("ChatInput", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Verify image paste was handled
|
||||
expect(onImagePaste).toHaveBeenCalledWith([file]);
|
||||
// Verify file paste was handled
|
||||
expect(onFilesPaste).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it("should use the default maxRows value", () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ const renderRepoConnector = () => {
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
path: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -50,13 +50,13 @@ const renderRepoConnector = () => {
|
||||
|
||||
const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
{
|
||||
id: 1,
|
||||
id: "1",
|
||||
full_name: "rbren/polaris",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: "2",
|
||||
full_name: "All-Hands-AI/OpenHands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
|
||||
@@ -94,13 +94,13 @@ describe("RepositorySelectionForm", () => {
|
||||
it("shows loading indicator when repositories are being fetched", () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: 1,
|
||||
id: "1",
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: "2",
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -122,13 +122,13 @@ describe("RepositorySelectionForm", () => {
|
||||
it("shows dropdown when repositories are loaded", async () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: 1,
|
||||
id: "1",
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: "2",
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -166,13 +166,13 @@ describe("RepositorySelectionForm", () => {
|
||||
it("should call the search repos API when searching a URL", async () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: 1,
|
||||
id: "1",
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: "2",
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -181,7 +181,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
const MOCK_SEARCH_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: 3,
|
||||
id: "3",
|
||||
full_name: "kubernetes/kubernetes",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -228,7 +228,7 @@ describe("RepositorySelectionForm", () => {
|
||||
it("should call onRepoSelection when a searched repository is selected", async () => {
|
||||
const MOCK_SEARCH_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: 3,
|
||||
id: "3",
|
||||
full_name: "kubernetes/kubernetes",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
|
||||
@@ -19,10 +19,10 @@ const MOCK_TASK_1: SuggestedTask = {
|
||||
};
|
||||
|
||||
const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
{ id: 1, full_name: "repo1", git_provider: "github", is_public: true },
|
||||
{ id: 2, full_name: "repo2", git_provider: "github", is_public: true },
|
||||
{ id: 3, full_name: "repo3", git_provider: "gitlab", is_public: true },
|
||||
{ id: 4, full_name: "repo4", git_provider: "gitlab", is_public: true },
|
||||
{ id: "1", full_name: "repo1", git_provider: "github", is_public: true },
|
||||
{ id: "2", full_name: "repo2", git_provider: "github", is_public: true },
|
||||
{ id: "3", full_name: "repo3", git_provider: "gitlab", is_public: true },
|
||||
{ id: "4", full_name: "repo4", git_provider: "gitlab", is_public: true },
|
||||
];
|
||||
|
||||
const renderTaskCard = (task = MOCK_TASK_1) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, within, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
@@ -92,7 +92,7 @@ describe("InteractiveChatBox", () => {
|
||||
await user.type(textarea, "Hello, world!");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file]);
|
||||
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file], []);
|
||||
|
||||
// clear images after submission
|
||||
expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
|
||||
@@ -144,7 +144,7 @@ describe("InteractiveChatBox", () => {
|
||||
onStop={onStop}
|
||||
onChange={onChange}
|
||||
value="test message"
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
// Upload an image via the upload button - this should NOT clear the text input
|
||||
@@ -161,7 +161,7 @@ describe("InteractiveChatBox", () => {
|
||||
await user.click(submitButton);
|
||||
|
||||
// Verify onSubmit was called with the message and image
|
||||
expect(onSubmit).toHaveBeenCalledWith("test message", [file]);
|
||||
expect(onSubmit).toHaveBeenCalledWith("test message", [file], []);
|
||||
|
||||
// Verify onChange was called to clear the text input
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
@@ -173,7 +173,7 @@ describe("InteractiveChatBox", () => {
|
||||
onStop={onStop}
|
||||
onChange={onChange}
|
||||
value=""
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the text input was cleared
|
||||
|
||||
@@ -41,19 +41,6 @@ describe("UploadImageInput", () => {
|
||||
expect(onUploadMock).toHaveBeenNthCalledWith(1, files);
|
||||
});
|
||||
|
||||
it("should not upload any file that is not an image", async () => {
|
||||
render(<UploadImageInput onUpload={onUploadMock} />);
|
||||
|
||||
const file = new File(["(⌐□_□)"], "chucknorris.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
const input = screen.getByTestId("upload-image-input");
|
||||
|
||||
await user.upload(input, file);
|
||||
|
||||
expect(onUploadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render custom labels", () => {
|
||||
const { rerender } = render(<UploadImageInput onUpload={onUploadMock} />);
|
||||
expect(screen.getByTestId("default-label")).toBeInTheDocument();
|
||||
|
||||
@@ -35,13 +35,13 @@ const queryClient = new QueryClient();
|
||||
const GitSettingsRouterStub = createRoutesStub([
|
||||
{
|
||||
Component: GitSettingsScreen,
|
||||
path: "/settings/github",
|
||||
path: "/settings/integrations",
|
||||
},
|
||||
]);
|
||||
|
||||
const renderGitSettingsScreen = () => {
|
||||
const { rerender, ...rest } = render(
|
||||
<GitSettingsRouterStub initialEntries={["/settings/github"]} />,
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -54,7 +54,7 @@ const renderGitSettingsScreen = () => {
|
||||
const rerenderGitSettingsScreen = () =>
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GitSettingsRouterStub initialEntries={["/settings/github"]} />
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -89,6 +89,9 @@ describe("Content", () => {
|
||||
await screen.findByTestId("gitlab-token-input");
|
||||
await screen.findByTestId("gitlab-token-help-anchor");
|
||||
|
||||
await screen.findByTestId("bitbucket-token-input");
|
||||
await screen.findByTestId("bitbucket-token-help-anchor");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
@@ -107,6 +110,13 @@ describe("Content", () => {
|
||||
expect(
|
||||
screen.queryByTestId("gitlab-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("bitbucket-token-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("bitbucket-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,6 +239,7 @@ describe("Content", () => {
|
||||
describe("Form submission", () => {
|
||||
it("should save the GitHub token", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
@@ -243,15 +254,49 @@ describe("Form submission", () => {
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "test-token", host: "" },
|
||||
gitlab: { token: "", host: "" },
|
||||
bitbucket: { token: "", host: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should save GitLab tokens", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "test-token", host: "" },
|
||||
github: { token: "", host: "" },
|
||||
gitlab: { token: "test-token", host: "" },
|
||||
bitbucket: { token: "", host: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should save the Bitbucket token", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const bitbucketInput = await screen.findByTestId("bitbucket-token-input");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.type(bitbucketInput, "test-token");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "", host: "" },
|
||||
gitlab: { token: "", host: "" },
|
||||
bitbucket: { token: "test-token", host: "" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,13 +45,13 @@ const renderHomeScreen = () =>
|
||||
|
||||
const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
{
|
||||
id: 1,
|
||||
id: "1",
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: "2",
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
|
||||
@@ -31,7 +31,7 @@ const RouterStub = createRoutesStub([
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
path: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ vi.mock("react-i18next", async () => {
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"SETTINGS$NAV_GIT": "Git",
|
||||
"SETTINGS$NAV_INTEGRATIONS": "Integrations",
|
||||
"SETTINGS$NAV_APPLICATION": "Application",
|
||||
"SETTINGS$NAV_CREDITS": "Credits",
|
||||
"SETTINGS$NAV_API_KEYS": "API Keys",
|
||||
@@ -61,7 +61,7 @@ describe("Settings Billing", () => {
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
path: "/settings/integrations",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async () => {
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
SETTINGS$NAV_GIT: "Git",
|
||||
SETTINGS$NAV_INTEGRATIONS: "Integrations",
|
||||
SETTINGS$NAV_APPLICATION: "Application",
|
||||
SETTINGS$NAV_CREDITS: "Credits",
|
||||
SETTINGS$NAV_API_KEYS: "API Keys",
|
||||
@@ -49,7 +49,7 @@ describe("Settings Screen", () => {
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
path: "/settings/integrations",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="application-settings-screen" />,
|
||||
@@ -79,7 +79,7 @@ describe("Settings Screen", () => {
|
||||
};
|
||||
|
||||
it("should render the navbar", async () => {
|
||||
const sectionsToInclude = ["llm", "git", "application", "secrets"];
|
||||
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
|
||||
const sectionsToExclude = ["api keys", "credits"];
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
@@ -111,7 +111,7 @@ describe("Settings Screen", () => {
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
const sectionsToInclude = [
|
||||
"git",
|
||||
"integrations",
|
||||
"application",
|
||||
"credits",
|
||||
"secrets",
|
||||
|
||||
3982
frontend/package-lock.json
generated
3982
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.43.0",
|
||||
"version": "0.44.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -22,23 +22,23 @@
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.9.0",
|
||||
"axios": "^1.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.17.3",
|
||||
"framer-motion": "^12.18.1",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.28",
|
||||
"jose": "^6.0.11",
|
||||
"lucide-react": "^0.514.0",
|
||||
"lucide-react": "^0.517.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.251.0",
|
||||
"posthog-js": "^1.255.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -83,7 +83,7 @@
|
||||
"@babel/parser": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.53.0",
|
||||
"@react-router/dev": "^7.6.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
@@ -92,7 +92,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -109,13 +109,13 @@
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-prettier": "^5.5.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.5.3",
|
||||
"stripe": "^18.2.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { GetFilesResponse, GetFileResponse } from "./file-service.types";
|
||||
import { getConversationUrl } from "../conversation.utils";
|
||||
import { FileUploadSuccessResponse } from "../open-hands.types";
|
||||
|
||||
export class FileService {
|
||||
/**
|
||||
@@ -35,4 +36,31 @@ export class FileService {
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param files List of files.
|
||||
* @returns list of uploaded files, list of skipped files
|
||||
*/
|
||||
static async uploadFiles(
|
||||
conversationId: string,
|
||||
files: File[],
|
||||
): Promise<FileUploadSuccessResponse> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
const url = `${getConversationUrl(conversationId)}/upload-files`;
|
||||
const response = await openHands.post<FileUploadSuccessResponse>(
|
||||
url,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,59 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit conversation feedback with rating
|
||||
* @param conversationId The conversation ID
|
||||
* @param rating The rating (1-5)
|
||||
* @param eventId Optional event ID this feedback corresponds to
|
||||
* @param reason Optional reason for the rating
|
||||
* @returns Response from the feedback endpoint
|
||||
*/
|
||||
static async submitConversationFeedback(
|
||||
conversationId: string,
|
||||
rating: number,
|
||||
eventId?: number,
|
||||
reason?: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
const url = `/feedback/conversation`;
|
||||
const payload = {
|
||||
conversation_id: conversationId,
|
||||
event_id: eventId,
|
||||
rating,
|
||||
reason,
|
||||
metadata: { source: "likert-scale" },
|
||||
};
|
||||
const { data } = await openHands.post<{ status: string; message: string }>(
|
||||
url,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if feedback exists for a specific conversation and event
|
||||
* @param conversationId The conversation ID
|
||||
* @param eventId The event ID to check
|
||||
* @returns Feedback data including existence, rating, and reason
|
||||
*/
|
||||
static async checkFeedbackExists(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
): Promise<{ exists: boolean; rating?: number; reason?: string }> {
|
||||
try {
|
||||
const url = `/feedback/conversation/${conversationId}/${eventId}`;
|
||||
const { data } = await openHands.get<{
|
||||
exists: boolean;
|
||||
rating?: number;
|
||||
reason?: string;
|
||||
}>(url);
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Error checking if feedback exists
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with GitHub token
|
||||
* @returns Response with authentication status and user info if successful
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface SaveFileSuccessResponse {
|
||||
}
|
||||
|
||||
export interface FileUploadSuccessResponse {
|
||||
message: string;
|
||||
uploaded_files: string[];
|
||||
skipped_files: { name: string; reason: string }[];
|
||||
}
|
||||
|
||||
1
frontend/src/assets/branding/bitbucket-logo.svg
Normal file
1
frontend/src/assets/branding/bitbucket-logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>
|
||||
|
After Width: | Height: | Size: 285 B |
@@ -20,19 +20,22 @@ export function ActionSuggestions({
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const isGitLab = providers.includes("gitlab");
|
||||
const isBitbucket = providers.includes("bitbucket");
|
||||
|
||||
const pr = isGitLab ? "merge request" : "pull request";
|
||||
const prShort = isGitLab ? "MR" : "PR";
|
||||
|
||||
const getProviderName = () => {
|
||||
if (isGitLab) return "GitLab";
|
||||
if (isBitbucket) return "Bitbucket";
|
||||
return "GitHub";
|
||||
};
|
||||
|
||||
const terms = {
|
||||
pr,
|
||||
prShort,
|
||||
pushToBranch: `Please push the changes to a remote branch on ${
|
||||
isGitLab ? "GitLab" : "GitHub"
|
||||
}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
|
||||
createPR: `Please push the changes to ${
|
||||
isGitLab ? "GitLab" : "GitHub"
|
||||
} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
|
||||
pushToBranch: `Please push the changes to a remote branch on ${getProviderName()}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
|
||||
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
|
||||
pushToPR: `Please push the latest changes to the existing ${pr}.`,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ChatInputProps {
|
||||
onChange?: (message: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onImagePaste?: (files: File[]) => void;
|
||||
onFilesPaste?: (files: File[]) => void;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
buttonClassName?: React.HTMLAttributes<HTMLButtonElement>["className"];
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function ChatInput({
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onImagePaste,
|
||||
onFilesPaste,
|
||||
className,
|
||||
buttonClassName,
|
||||
}: ChatInputProps) {
|
||||
@@ -45,15 +45,11 @@ export function ChatInput({
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// Only handle paste if we have an image paste handler and there are files
|
||||
if (onImagePaste && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
if (onFilesPaste && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
// Only prevent default if we found image files to handle
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onImagePaste(files);
|
||||
}
|
||||
event.preventDefault();
|
||||
onFilesPaste(files);
|
||||
}
|
||||
// For text paste, let the default behavior handle it
|
||||
};
|
||||
@@ -73,12 +69,10 @@ export function ChatInput({
|
||||
const handleDrop = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
if (onImagePaste && event.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
if (onFilesPaste && event.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
onImagePaste(files);
|
||||
onFilesPaste(files);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { Messages } from "./messages";
|
||||
import { ChatSuggestions } from "./chat-suggestions";
|
||||
import { ActionSuggestions } from "./action-suggestions";
|
||||
import { ScrollProvider } from "#/context/scroll-context";
|
||||
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -28,6 +29,8 @@ import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
|
||||
import { ErrorMessageBanner } from "./error-message-banner";
|
||||
import { shouldRenderEvent } from "./event-content-helpers/should-render-event";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -45,8 +48,15 @@ export function ChatInterface() {
|
||||
useOptimisticUserMessage();
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
|
||||
useScrollToBottom(scrollRef);
|
||||
const {
|
||||
scrollDomToBottom,
|
||||
onChatBodyScroll,
|
||||
hitBottom,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
setHitBottom,
|
||||
} = useScrollToBottom(scrollRef);
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
@@ -60,13 +70,18 @@ export function ChatInterface() {
|
||||
);
|
||||
const params = useParams();
|
||||
const { mutate: getTrajectory } = useGetTrajectory();
|
||||
const { mutateAsync: uploadFiles } = useUploadFiles();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
const errorMessage = getErrorMessage();
|
||||
|
||||
const events = parsedEvents.filter(shouldRenderEvent);
|
||||
|
||||
const handleSendMessage = async (content: string, files: File[]) => {
|
||||
const handleSendMessage = async (
|
||||
content: string,
|
||||
images: File[],
|
||||
files: File[],
|
||||
) => {
|
||||
if (events.length === 0) {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: getEntryPoint(
|
||||
@@ -82,11 +97,23 @@ export function ChatInterface() {
|
||||
current_message_length: content.length,
|
||||
});
|
||||
}
|
||||
const promises = files.map((file) => convertImageToBase64(file));
|
||||
const promises = images.map((image) => convertImageToBase64(image));
|
||||
const imageUrls = await Promise.all(promises);
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
send(createChatMessage(content, imageUrls, timestamp));
|
||||
|
||||
const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } =
|
||||
files.length > 0
|
||||
? await uploadFiles({ conversationId: params.conversationId!, files })
|
||||
: { skipped_files: [], uploaded_files: [] };
|
||||
|
||||
skippedFiles.forEach((f) => displayErrorToast(f.reason));
|
||||
|
||||
const filePrompt = `${t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE")}: ${uploadedFiles.join("\n\n")}`;
|
||||
const prompt =
|
||||
uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content;
|
||||
|
||||
send(createChatMessage(prompt, imageUrls, uploadedFiles, timestamp));
|
||||
setOptimisticUserMessage(content);
|
||||
setMessageToSend(null);
|
||||
};
|
||||
@@ -126,80 +153,97 @@ export function ChatInterface() {
|
||||
curAgentState === AgentState.AWAITING_USER_INPUT ||
|
||||
curAgentState === AgentState.FINISHED;
|
||||
|
||||
// Create a ScrollProvider with the scroll hook values
|
||||
const scrollProviderValue = {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
scrollDomToBottom,
|
||||
hitBottom,
|
||||
setHitBottom,
|
||||
onChatBodyScroll,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{events.length === 0 && !optimisticUserMessage && (
|
||||
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
className="scrollbar scrollbar-thin scrollbar-thumb-gray-400 scrollbar-thumb-rounded-full scrollbar-track-gray-800 hover:scrollbar-thumb-gray-300 flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
|
||||
>
|
||||
{isLoadingMessages && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
<ScrollProvider value={scrollProviderValue}>
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{events.length === 0 && !optimisticUserMessage && (
|
||||
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && (
|
||||
<Messages
|
||||
messages={events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
className="scrollbar scrollbar-thin scrollbar-thumb-gray-400 scrollbar-thumb-rounded-full scrollbar-track-gray-800 hover:scrollbar-thumb-gray-300 flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
|
||||
>
|
||||
{isLoadingMessages && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWaitingForUserInput &&
|
||||
events.length > 0 &&
|
||||
!optimisticUserMessage && (
|
||||
<ActionSuggestions
|
||||
onSuggestionsClick={(value) => handleSendMessage(value, [])}
|
||||
{!isLoadingMessages && (
|
||||
<Messages
|
||||
messages={events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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()}
|
||||
/>
|
||||
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
|
||||
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
|
||||
</div>
|
||||
|
||||
{!hitBottom && <ScrollToBottomButton onClick={scrollDomToBottom} />}
|
||||
{isWaitingForUserInput &&
|
||||
events.length > 0 &&
|
||||
!optimisticUserMessage && (
|
||||
<ActionSuggestions
|
||||
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && <ErrorMessageBanner message={errorMessage} />}
|
||||
<div className="flex flex-col gap-[6px] px-4 pb-4">
|
||||
<div className="flex justify-between relative">
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={() =>
|
||||
onClickShareFeedbackActionButton("positive")
|
||||
}
|
||||
onNegativeFeedback={() =>
|
||||
onClickShareFeedbackActionButton("negative")
|
||||
}
|
||||
onExportTrajectory={() => onClickExportTrajectoryButton()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InteractiveChatBox
|
||||
onSubmit={handleSendMessage}
|
||||
onStop={handleStop}
|
||||
isDisabled={
|
||||
curAgentState === AgentState.LOADING ||
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
|
||||
value={messageToSend ?? undefined}
|
||||
onChange={setMessageToSend}
|
||||
/>
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
|
||||
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
|
||||
</div>
|
||||
|
||||
{!hitBottom && <ScrollToBottomButton onClick={scrollDomToBottom} />}
|
||||
</div>
|
||||
|
||||
{errorMessage && <ErrorMessageBanner message={errorMessage} />}
|
||||
|
||||
<InteractiveChatBox
|
||||
onSubmit={handleSendMessage}
|
||||
onStop={handleStop}
|
||||
isDisabled={
|
||||
curAgentState === AgentState.LOADING ||
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
|
||||
value={messageToSend ?? undefined}
|
||||
onChange={setMessageToSend}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
<FeedbackModal
|
||||
isOpen={feedbackModalIsOpen}
|
||||
onClose={() => setFeedbackModalIsOpen(false)}
|
||||
polarity={feedbackPolarity}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FeedbackModal
|
||||
isOpen={feedbackModalIsOpen}
|
||||
onClose={() => setFeedbackModalIsOpen(false)}
|
||||
polarity={feedbackPolarity}
|
||||
/>
|
||||
</div>
|
||||
</ScrollProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import i18n from "#/i18n";
|
||||
import { isUserMessage } from "#/types/core/guards";
|
||||
|
||||
export const parseMessageFromEvent = (
|
||||
event: UserMessageAction | AssistantMessageAction,
|
||||
): string => {
|
||||
const m = isUserMessage(event) ? event.args.content : event.message;
|
||||
if (!event.args.file_urls || event.args.file_urls.length === 0) {
|
||||
return m;
|
||||
}
|
||||
const delimiter = i18n.t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE");
|
||||
const parts = m.split(delimiter);
|
||||
|
||||
return parts[0];
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import {
|
||||
@@ -18,6 +19,12 @@ import { MCPObservationContent } from "./mcp-observation-content";
|
||||
import { getObservationResult } from "./event-content-helpers/get-observation-result";
|
||||
import { getEventContent } from "./event-content-helpers/get-event-content";
|
||||
import { GenericEventMessage } from "./generic-event-message";
|
||||
import { FileList } from "../files/file-list";
|
||||
import { parseMessageFromEvent } from "./event-content-helpers/parse-message-from-event";
|
||||
import { LikertScale } from "../feedback/likert-scale";
|
||||
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
|
||||
|
||||
const hasThoughtProperty = (
|
||||
obj: Record<string, unknown>,
|
||||
@@ -39,6 +46,14 @@ export function EventMessage({
|
||||
const shouldShowConfirmationButtons =
|
||||
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// Use our query hook to check if feedback exists and get rating/reason
|
||||
const {
|
||||
data: feedbackData = { exists: false },
|
||||
isLoading: isCheckingFeedback,
|
||||
} = useFeedbackExists(isFinishAction(event) ? event.id : undefined);
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
@@ -55,21 +70,39 @@ export function EventMessage({
|
||||
return null;
|
||||
}
|
||||
|
||||
const showLikertScale =
|
||||
config?.APP_MODE === "saas" &&
|
||||
isFinishAction(event) &&
|
||||
isLastMessage &&
|
||||
!isCheckingFeedback;
|
||||
|
||||
if (isFinishAction(event)) {
|
||||
return (
|
||||
<ChatMessage type="agent" message={getEventContent(event).details} />
|
||||
<>
|
||||
<ChatMessage type="agent" message={getEventContent(event).details} />
|
||||
{showLikertScale && (
|
||||
<LikertScale
|
||||
eventId={event.id}
|
||||
initiallySubmitted={feedbackData.exists}
|
||||
initialRating={feedbackData.rating}
|
||||
initialReason={feedbackData.reason}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUserMessage(event) || isAssistantMessage(event)) {
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
type={event.source}
|
||||
message={isUserMessage(event) ? event.args.content : event.message}
|
||||
>
|
||||
<ChatMessage type={event.source} message={message}>
|
||||
{event.args.image_urls && event.args.image_urls.length > 0 && (
|
||||
<ImageCarousel size="small" images={event.args.image_urls} />
|
||||
)}
|
||||
{event.args.file_urls && event.args.file_urls.length > 0 && (
|
||||
<FileList files={event.args.file_urls} />
|
||||
)}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,13 @@ import { ChatInput } from "./chat-input";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ImageCarousel } from "../images/image-carousel";
|
||||
import { UploadImageInput } from "../images/upload-image-input";
|
||||
import { FileList } from "../files/file-list";
|
||||
import { isFileImage } from "#/utils/is-file-image";
|
||||
|
||||
interface InteractiveChatBoxProps {
|
||||
isDisabled?: boolean;
|
||||
mode?: "stop" | "submit";
|
||||
onSubmit: (message: string, images: File[]) => void;
|
||||
onSubmit: (message: string, images: File[], files: File[]) => void;
|
||||
onStop: () => void;
|
||||
value?: string;
|
||||
onChange?: (message: string) => void;
|
||||
@@ -22,21 +24,35 @@ export function InteractiveChatBox({
|
||||
onChange,
|
||||
}: InteractiveChatBoxProps) {
|
||||
const [images, setImages] = React.useState<File[]>([]);
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
|
||||
const handleUpload = (files: File[]) => {
|
||||
setImages((prevImages) => [...prevImages, ...files]);
|
||||
const handleUpload = (selectedFiles: File[]) => {
|
||||
setFiles((prevFiles) => [
|
||||
...prevFiles,
|
||||
...selectedFiles.filter((f) => !isFileImage(f)),
|
||||
]);
|
||||
setImages((prevImages) => [
|
||||
...prevImages,
|
||||
...selectedFiles.filter((f) => isFileImage(f)),
|
||||
]);
|
||||
};
|
||||
|
||||
const removeElementByIndex = (array: Array<File>, index: number) => {
|
||||
const newArray = [...array];
|
||||
newArray.splice(index, 1);
|
||||
return newArray;
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
setFiles(removeElementByIndex(files, index));
|
||||
};
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages((prevImages) => {
|
||||
const newImages = [...prevImages];
|
||||
newImages.splice(index, 1);
|
||||
return newImages;
|
||||
});
|
||||
setImages(removeElementByIndex(images, index));
|
||||
};
|
||||
|
||||
const handleSubmit = (message: string) => {
|
||||
onSubmit(message, images);
|
||||
onSubmit(message, images, files);
|
||||
setFiles([]);
|
||||
setImages([]);
|
||||
if (message) {
|
||||
onChange?.("");
|
||||
@@ -55,6 +71,12 @@ export function InteractiveChatBox({
|
||||
onRemove={handleRemoveImage}
|
||||
/>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<FileList
|
||||
files={files.map((f) => f.name)}
|
||||
onRemove={handleRemoveFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
@@ -72,7 +94,7 @@ export function InteractiveChatBox({
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
value={value}
|
||||
onImagePaste={handleUpload}
|
||||
onFilesPaste={handleUpload}
|
||||
className="py-[10px]"
|
||||
buttonClassName="py-[10px]"
|
||||
/>
|
||||
|
||||
248
frontend/src/components/features/feedback/likert-scale.tsx
Normal file
248
frontend/src/components/features/feedback/likert-scale.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import i18n from "#/i18n";
|
||||
import { useSubmitConversationFeedback } from "#/hooks/mutation/use-submit-conversation-feedback";
|
||||
import { ScrollContext } from "#/context/scroll-context";
|
||||
|
||||
// Global timeout duration in milliseconds
|
||||
const AUTO_SUBMIT_TIMEOUT = 10000;
|
||||
|
||||
interface LikertScaleProps {
|
||||
eventId?: number;
|
||||
initiallySubmitted?: boolean;
|
||||
initialRating?: number;
|
||||
initialReason?: string;
|
||||
}
|
||||
|
||||
const FEEDBACK_REASONS = [
|
||||
i18n.t("FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION"),
|
||||
i18n.t("FEEDBACK$REASON_FORGOT_CONTEXT"),
|
||||
i18n.t("FEEDBACK$REASON_UNNECESSARY_CHANGES"),
|
||||
i18n.t("FEEDBACK$REASON_OTHER"),
|
||||
];
|
||||
|
||||
export function LikertScale({
|
||||
eventId,
|
||||
initiallySubmitted = false,
|
||||
initialRating,
|
||||
initialReason,
|
||||
}: LikertScaleProps) {
|
||||
const [selectedRating, setSelectedRating] = useState<number | null>(
|
||||
initialRating || null,
|
||||
);
|
||||
const [selectedReason, setSelectedReason] = useState<string | null>(
|
||||
initialReason || null,
|
||||
);
|
||||
const [showReasons, setShowReasons] = useState(false);
|
||||
const [reasonTimeout, setReasonTimeout] = useState<NodeJS.Timeout | null>(
|
||||
null,
|
||||
);
|
||||
const [isSubmitted, setIsSubmitted] = useState(initiallySubmitted);
|
||||
const [countdown, setCountdown] = useState<number>(0);
|
||||
|
||||
// Get scroll context
|
||||
const scrollContext = useContext(ScrollContext);
|
||||
|
||||
// If scrollContext is undefined, we're not inside a ScrollProvider
|
||||
const scrollToBottom = scrollContext?.scrollDomToBottom;
|
||||
const autoScroll = scrollContext?.autoScroll;
|
||||
|
||||
// Use our mutation hook
|
||||
const { mutate: submitConversationFeedback } =
|
||||
useSubmitConversationFeedback();
|
||||
|
||||
// Update isSubmitted if initiallySubmitted changes
|
||||
useEffect(() => {
|
||||
setIsSubmitted(initiallySubmitted);
|
||||
}, [initiallySubmitted]);
|
||||
|
||||
// Update selectedRating if initialRating changes
|
||||
useEffect(() => {
|
||||
if (initialRating) {
|
||||
setSelectedRating(initialRating);
|
||||
}
|
||||
}, [initialRating]);
|
||||
|
||||
// Update selectedReason if initialReason changes
|
||||
useEffect(() => {
|
||||
if (initialReason) {
|
||||
setSelectedReason(initialReason);
|
||||
}
|
||||
}, [initialReason]);
|
||||
|
||||
// Submit feedback and disable the component
|
||||
const submitFeedback = (rating: number, reason?: string) => {
|
||||
submitConversationFeedback(
|
||||
{
|
||||
rating,
|
||||
eventId,
|
||||
reason,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedReason(reason || null);
|
||||
setShowReasons(false);
|
||||
setIsSubmitted(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Handle star rating selection
|
||||
const handleRatingClick = (rating: number) => {
|
||||
if (isSubmitted) return; // Prevent changes after submission
|
||||
|
||||
setSelectedRating(rating);
|
||||
|
||||
// Only show reasons if rating is 3 or less (1, 2, or 3 stars)
|
||||
// For ratings > 3 (4 or 5 stars), submit immediately without showing reasons
|
||||
if (rating <= 3) {
|
||||
setShowReasons(true);
|
||||
setCountdown(Math.ceil(AUTO_SUBMIT_TIMEOUT / 1000));
|
||||
|
||||
// Set a timeout to auto-submit if no reason is selected
|
||||
const timeout = setTimeout(() => {
|
||||
submitFeedback(rating);
|
||||
}, AUTO_SUBMIT_TIMEOUT);
|
||||
|
||||
setReasonTimeout(timeout);
|
||||
|
||||
// Only scroll to bottom if the user is already at the bottom (autoScroll is true)
|
||||
if (scrollToBottom && autoScroll) {
|
||||
// Small delay to ensure the reasons are fully rendered
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
// For ratings > 3 (4 or 5 stars), submit immediately without showing reasons
|
||||
setShowReasons(false);
|
||||
submitFeedback(rating);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle reason selection
|
||||
const handleReasonClick = (reason: string) => {
|
||||
if (selectedRating && reasonTimeout && !isSubmitted) {
|
||||
clearTimeout(reasonTimeout);
|
||||
setCountdown(0);
|
||||
submitFeedback(selectedRating, reason);
|
||||
}
|
||||
};
|
||||
|
||||
// Countdown effect
|
||||
useEffect(() => {
|
||||
if (countdown > 0 && showReasons && !isSubmitted) {
|
||||
const timer = setTimeout(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return () => {};
|
||||
}, [countdown, showReasons, isSubmitted]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (reasonTimeout) {
|
||||
clearTimeout(reasonTimeout);
|
||||
}
|
||||
},
|
||||
[reasonTimeout],
|
||||
);
|
||||
|
||||
// Scroll to bottom when component mounts, but only if user is already at the bottom
|
||||
useEffect(() => {
|
||||
if (scrollToBottom && autoScroll && !isSubmitted) {
|
||||
// Small delay to ensure the component is fully rendered
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
}, [scrollToBottom, autoScroll, isSubmitted]);
|
||||
|
||||
// Scroll to bottom when reasons are shown, but only if user is already at the bottom
|
||||
useEffect(() => {
|
||||
if (scrollToBottom && autoScroll && showReasons) {
|
||||
// Small delay to ensure the reasons are fully rendered
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
}, [scrollToBottom, autoScroll, showReasons]);
|
||||
|
||||
// Helper function to get button class based on state
|
||||
const getButtonClass = (rating: number) => {
|
||||
if (isSubmitted) {
|
||||
return selectedRating && selectedRating >= rating
|
||||
? "text-yellow-400 cursor-not-allowed"
|
||||
: "text-gray-300 opacity-50 cursor-not-allowed";
|
||||
}
|
||||
|
||||
return selectedRating && selectedRating >= rating
|
||||
? "text-yellow-400"
|
||||
: "text-gray-300 hover:text-yellow-200";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<div className="text-sm text-gray-500 mb-1">
|
||||
{isSubmitted
|
||||
? i18n.t("FEEDBACK$THANK_YOU_FOR_FEEDBACK")
|
||||
: i18n.t("FEEDBACK$RATE_AGENT_PERFORMANCE")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex gap-2 items-center flex-wrap">
|
||||
{[1, 2, 3, 4, 5].map((rating) => (
|
||||
<button
|
||||
type="button"
|
||||
key={rating}
|
||||
onClick={() => handleRatingClick(rating)}
|
||||
disabled={isSubmitted}
|
||||
className={cn("text-xl transition-all", getButtonClass(rating))}
|
||||
aria-label={`Rate ${rating} stars`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
{/* Show selected reason inline with stars when submitted (only for ratings <= 3) */}
|
||||
{isSubmitted &&
|
||||
selectedReason &&
|
||||
selectedRating &&
|
||||
selectedRating <= 3 && (
|
||||
<span className="text-sm text-gray-500 italic">
|
||||
{selectedReason}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showReasons && !isSubmitted && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<div className="text-xs text-gray-500 mb-1">
|
||||
{i18n.t("FEEDBACK$SELECT_REASON")}
|
||||
</div>
|
||||
{countdown > 0 && (
|
||||
<div className="text-xs text-gray-400 mb-1 italic">
|
||||
{i18n.t("FEEDBACK$SELECT_REASON_COUNTDOWN", {
|
||||
countdown,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{FEEDBACK_REASONS.map((reason) => (
|
||||
<button
|
||||
type="button"
|
||||
key={reason}
|
||||
onClick={() => handleReasonClick(reason)}
|
||||
className="text-sm text-left py-1 px-2 rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{reason}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/features/files/file-item.tsx
Normal file
20
frontend/src/components/features/files/file-item.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { FaFile } from "react-icons/fa";
|
||||
import { RemoveButton } from "#/components/shared/buttons/remove-button";
|
||||
|
||||
interface FileItemProps {
|
||||
filename: string;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function FileItem({ filename, onRemove }: FileItemProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="file-item"
|
||||
className="flex flex-row gap-x-1 items-center justify-start"
|
||||
>
|
||||
<FaFile className="h-4 w-4" />
|
||||
<code className="text-sm flex-1 text-white truncate">{filename}</code>
|
||||
{onRemove && <RemoveButton onClick={onRemove} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/features/files/file-list.tsx
Normal file
25
frontend/src/components/features/files/file-list.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { FileItem } from "./file-item";
|
||||
|
||||
interface FileListProps {
|
||||
files: string[];
|
||||
onRemove?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function FileList({ files, onRemove }: FileListProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="file-list"
|
||||
className={cn("flex flex-col gap-y-1.5 justify-start")}
|
||||
>
|
||||
{files.map((f, index) => (
|
||||
<FileItem
|
||||
key={index}
|
||||
filename={f}
|
||||
onRemove={onRemove ? () => onRemove?.(index) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,10 @@ export function ConnectToProviderMessage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p>{t("HOME$CONNECT_PROVIDER_MESSAGE")}</p>
|
||||
<Link data-testid="navigate-to-settings-button" to="/settings/git">
|
||||
<Link
|
||||
data-testid="navigate-to-settings-button"
|
||||
to="/settings/integrations"
|
||||
>
|
||||
<BrandButton type="button" variant="primary" isDisabled={isLoading}>
|
||||
{!isLoading && t("SETTINGS$TITLE")}
|
||||
{isLoading && t("HOME$LOADING")}
|
||||
|
||||
@@ -93,9 +93,7 @@ export function RepositorySelectionForm({
|
||||
}));
|
||||
|
||||
const handleRepoSelection = (key: React.Key | null) => {
|
||||
const selectedRepo = allRepositories?.find(
|
||||
(repo) => repo.id.toString() === key,
|
||||
);
|
||||
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
|
||||
|
||||
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
|
||||
setSelectedRepository(selectedRepo || null);
|
||||
|
||||
@@ -54,6 +54,10 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
const issueType =
|
||||
task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests";
|
||||
href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`;
|
||||
} else if (task.git_provider === "bitbucket") {
|
||||
const issueType =
|
||||
task.task_type === "OPEN_ISSUE" ? "issues" : "pull-requests";
|
||||
href = `https://bitbucket.org/${task.repo}/${issueType}/${task.issue_number}`;
|
||||
} else {
|
||||
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
|
||||
href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ImageCarousel({
|
||||
key={index}
|
||||
size={size}
|
||||
src={src}
|
||||
onRemove={onRemove && (() => onRemove(index))}
|
||||
onRemove={onRemove ? () => onRemove?.(index) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,12 @@ export function ImagePreview({
|
||||
return (
|
||||
<div data-testid="image-preview" className="relative w-fit shrink-0">
|
||||
<Thumbnail src={src} size={size} />
|
||||
{onRemove && <RemoveButton onClick={onRemove} />}
|
||||
{onRemove && (
|
||||
<RemoveButton
|
||||
onClick={onRemove}
|
||||
className="absolute right-[3px] top-[3px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,7 @@ interface UploadImageInputProps {
|
||||
export function UploadImageInput({ onUpload, label }: UploadImageInputProps) {
|
||||
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files) {
|
||||
const validFiles = Array.from(event.target.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
onUpload(validFiles);
|
||||
onUpload(Array.from(event.target.files));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,7 +18,6 @@ export function UploadImageInput({ onUpload, label }: UploadImageInputProps) {
|
||||
<input
|
||||
data-testid="upload-image-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleUpload}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function BitbucketTokenHelpAnchor() {
|
||||
return (
|
||||
<p data-testid="bitbucket-token-help-anchor" className="text-xs">
|
||||
<Trans
|
||||
i18nKey={I18nKey.BITBUCKET$TOKEN_HELP_TEXT}
|
||||
components={[
|
||||
<a
|
||||
key="bitbucket-token-help-anchor-link"
|
||||
aria-label="Bitbucket token help link"
|
||||
href="https://bitbucket.org/account/settings/app-passwords/new?scopes=repository:write,pullrequest:write,issue:write"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
<a
|
||||
key="bitbucket-token-help-anchor-link-2"
|
||||
aria-label="Bitbucket token see more link"
|
||||
href="https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
import { BitbucketTokenHelpAnchor } from "./bitbucket-token-help-anchor";
|
||||
import { KeyStatusIcon } from "../key-status-icon";
|
||||
|
||||
interface BitbucketTokenInputProps {
|
||||
onChange: (value: string) => void;
|
||||
onBitbucketHostChange: (value: string) => void;
|
||||
isBitbucketTokenSet: boolean;
|
||||
name: string;
|
||||
bitbucketHostSet: string | null | undefined;
|
||||
}
|
||||
|
||||
export function BitbucketTokenInput({
|
||||
onChange,
|
||||
onBitbucketHostChange,
|
||||
isBitbucketTokenSet,
|
||||
name,
|
||||
bitbucketHostSet,
|
||||
}: BitbucketTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsInput
|
||||
testId={name}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
label={t(I18nKey.BITBUCKET$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-full max-w-[680px]"
|
||||
placeholder={isBitbucketTokenSet ? "<hidden>" : "username:app_password"}
|
||||
startContent={
|
||||
isBitbucketTokenSet && (
|
||||
<KeyStatusIcon
|
||||
testId="bb-set-token-indicator"
|
||||
isSet={isBitbucketTokenSet}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
onChange={onBitbucketHostChange || (() => {})}
|
||||
name="bitbucket-host-input"
|
||||
testId="bitbucket-host-input"
|
||||
label={t(I18nKey.BITBUCKET$HOST_LABEL)}
|
||||
type="text"
|
||||
className="w-full max-w-[680px]"
|
||||
placeholder="bitbucket.org"
|
||||
defaultValue={bitbucketHostSet || undefined}
|
||||
startContent={
|
||||
bitbucketHostSet &&
|
||||
bitbucketHostSet.trim() !== "" && (
|
||||
<KeyStatusIcon testId="bb-set-host-indicator" isSet />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<BitbucketTokenHelpAnchor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
|
||||
export function InstallSlackAppAnchor() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<a
|
||||
data-testid="install-slack-app-button"
|
||||
href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,chat:write,users:read,channels:history,groups:history,mpim:history,im:history&user_scope=channels:history,groups:history,im:history,mpim:history"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="py-9"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
{t(I18nKey.SLACK$INSTALL_APP)}
|
||||
</BrandButton>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
|
||||
import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
|
||||
import { useAuthUrl } from "#/hooks/use-auth-url";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
|
||||
@@ -23,6 +24,11 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
identityProvider: "gitlab",
|
||||
});
|
||||
|
||||
const bitbucketAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "bitbucket",
|
||||
});
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
@@ -37,6 +43,13 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBitbucketAuth = () => {
|
||||
if (bitbucketAuthUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = bitbucketAuthUrl;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
@@ -67,6 +80,16 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
>
|
||||
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleBitbucketAuth}
|
||||
className="w-full"
|
||||
startContent={<BitbucketLogo width={20} height={20} />}
|
||||
>
|
||||
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
|
||||
@@ -3,19 +3,20 @@ import CloseIcon from "#/icons/close.svg?react";
|
||||
|
||||
interface RemoveButtonProps {
|
||||
onClick: () => void;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
}
|
||||
|
||||
export function RemoveButton({ onClick }: RemoveButtonProps) {
|
||||
export function RemoveButton({ onClick, className }: RemoveButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"bg-neutral-400 rounded-full w-3 h-3 flex items-center justify-center",
|
||||
"absolute right-[3px] top-[3px]",
|
||||
"bg-neutral-400 rounded-full w-5 h-5 flex items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CloseIcon width={10} height={10} />
|
||||
<CloseIcon width={18} height={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import React from "react";
|
||||
import { useNavigation } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import { addFile, removeFile } from "#/state/initial-query-slice";
|
||||
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { ChatInput } from "#/components/features/chat/chat-input";
|
||||
import { getRandomKey } from "#/utils/get-random-key";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { AttachImageLabel } from "../features/images/attach-image-label";
|
||||
import { ImageCarousel } from "../features/images/image-carousel";
|
||||
import { UploadImageInput } from "../features/images/upload-image-input";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
|
||||
interface TaskFormProps {
|
||||
ref: React.RefObject<HTMLFormElement | null>;
|
||||
}
|
||||
|
||||
export function TaskForm({ ref }: TaskFormProps) {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { files } = useSelector((state: RootState) => state.initialQuery);
|
||||
|
||||
const [text, setText] = React.useState("");
|
||||
const [suggestion, setSuggestion] = React.useState(() => {
|
||||
const key = getRandomKey(SUGGESTIONS["non-repo"]);
|
||||
return { key, value: SUGGESTIONS["non-repo"][key] };
|
||||
});
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
|
||||
const onRefreshSuggestion = () => {
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
// remove current suggestion to avoid refreshing to the same suggestion
|
||||
const suggestionCopy = { ...suggestions };
|
||||
delete suggestionCopy[suggestion.key];
|
||||
|
||||
const key = getRandomKey(suggestionCopy);
|
||||
setSuggestion({ key, value: suggestions[key] });
|
||||
};
|
||||
|
||||
const onClickSuggestion = () => {
|
||||
setText(suggestion.value);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const q = formData.get("q")?.toString();
|
||||
createConversation({ q });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<form
|
||||
ref={ref}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col items-center gap-2"
|
||||
>
|
||||
<SuggestionBubble
|
||||
suggestion={suggestion}
|
||||
onClick={onClickSuggestion}
|
||||
onRefresh={onRefreshSuggestion}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"border border-neutral-600 px-4 rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
|
||||
inputIsFocused ? "bg-neutral-600" : "bg-tertiary",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<div className="flex justify-center py-[17px]">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<ChatInput
|
||||
name="q"
|
||||
onSubmit={() => {
|
||||
if (typeof ref !== "function") ref?.current?.requestSubmit();
|
||||
}}
|
||||
onChange={(message) => setText(message)}
|
||||
onFocus={() => setInputIsFocused(true)}
|
||||
onBlur={() => setInputIsFocused(false)}
|
||||
onImagePaste={async (imageFiles) => {
|
||||
const promises = imageFiles.map(convertImageToBase64);
|
||||
const base64Images = await Promise.all(promises);
|
||||
base64Images.forEach((base64) => {
|
||||
dispatch(addFile(base64));
|
||||
});
|
||||
}}
|
||||
value={text}
|
||||
maxRows={15}
|
||||
showButton={!!text}
|
||||
className="text-[17px] leading-5 py-[17px]"
|
||||
buttonClassName="pb-[17px]"
|
||||
disabled={navigation.state === "submitting"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<UploadImageInput
|
||||
onUpload={async (uploadedFiles) => {
|
||||
const promises = uploadedFiles.map(convertImageToBase64);
|
||||
const base64Images = await Promise.all(promises);
|
||||
base64Images.forEach((base64) => {
|
||||
dispatch(addFile(base64));
|
||||
});
|
||||
}}
|
||||
label={<AttachImageLabel />}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<ImageCarousel
|
||||
size="large"
|
||||
images={files}
|
||||
onRemove={(index) => dispatch(removeFile(index))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/src/context/scroll-context.tsx
Normal file
42
frontend/src/context/scroll-context.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { createContext, useContext, ReactNode, RefObject } from "react";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
|
||||
interface ScrollContextType {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
autoScroll: boolean;
|
||||
setAutoScroll: (value: boolean) => void;
|
||||
scrollDomToBottom: () => void;
|
||||
hitBottom: boolean;
|
||||
setHitBottom: (value: boolean) => void;
|
||||
onChatBodyScroll: (e: HTMLElement) => void;
|
||||
}
|
||||
|
||||
export const ScrollContext = createContext<ScrollContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ScrollProviderProps {
|
||||
children: ReactNode;
|
||||
value?: ScrollContextType;
|
||||
}
|
||||
|
||||
export function ScrollProvider({ children, value }: ScrollProviderProps) {
|
||||
const scrollHook = useScrollToBottom(React.useRef<HTMLDivElement>(null));
|
||||
|
||||
// Use provided value or default to the hook
|
||||
const contextValue = value || scrollHook;
|
||||
|
||||
return (
|
||||
<ScrollContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ScrollContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useScrollContext() {
|
||||
const context = useContext(ScrollContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useScrollContext must be used within a ScrollProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
type SubmitConversationFeedbackArgs = {
|
||||
rating: number;
|
||||
eventId?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export const useSubmitConversationFeedback = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ rating, eventId, reason }: SubmitConversationFeedbackArgs) =>
|
||||
OpenHands.submitConversationFeedback(
|
||||
conversationId,
|
||||
rating,
|
||||
eventId,
|
||||
reason,
|
||||
),
|
||||
onSuccess: (_, { eventId }) => {
|
||||
// Invalidate the feedback existence query to trigger a refetch
|
||||
if (eventId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["feedback", "exists", conversationId, eventId],
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Log error but don't show toast - user will just see the UI stay in unsubmitted state
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(t("FEEDBACK$FAILED_TO_SUBMIT"), error);
|
||||
},
|
||||
});
|
||||
};
|
||||
13
frontend/src/hooks/mutation/use-upload-files.ts
Normal file
13
frontend/src/hooks/mutation/use-upload-files.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
export const useUploadFiles = () =>
|
||||
useMutation({
|
||||
mutationKey: ["upload-files"],
|
||||
mutationFn: (variables: { conversationId: string; files: File[] }) =>
|
||||
FileService.uploadFiles(variables.conversationId!, variables.files),
|
||||
onSuccess: async () => {},
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
24
frontend/src/hooks/query/use-feedback-exists.ts
Normal file
24
frontend/src/hooks/query/use-feedback-exists.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
export interface FeedbackData {
|
||||
exists: boolean;
|
||||
rating?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export const useFeedbackExists = (eventId?: number) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery<FeedbackData>({
|
||||
queryKey: ["feedback", "exists", conversationId, eventId],
|
||||
queryFn: () => {
|
||||
if (!eventId) return { exists: false };
|
||||
return OpenHands.checkFeedbackExists(conversationId, eventId);
|
||||
},
|
||||
enabled: !!eventId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -15,7 +15,7 @@ export const useAutoLogin = () => {
|
||||
// Get the stored login method
|
||||
const loginMethod = getLoginMethod();
|
||||
|
||||
// Get the auth URLs for both providers
|
||||
// Get the auth URLs for all providers
|
||||
const githubAuthUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "github",
|
||||
@@ -26,6 +26,11 @@ export const useAutoLogin = () => {
|
||||
identityProvider: "gitlab",
|
||||
});
|
||||
|
||||
const bitbucketAuthUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "bitbucket",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-login in SAAS mode
|
||||
if (config?.APP_MODE !== "saas") {
|
||||
@@ -48,8 +53,14 @@ export const useAutoLogin = () => {
|
||||
}
|
||||
|
||||
// Get the appropriate auth URL based on the stored login method
|
||||
const authUrl =
|
||||
loginMethod === LoginMethod.GITHUB ? githubAuthUrl : gitlabAuthUrl;
|
||||
let authUrl: string | null = null;
|
||||
if (loginMethod === LoginMethod.GITHUB) {
|
||||
authUrl = githubAuthUrl;
|
||||
} else if (loginMethod === LoginMethod.GITLAB) {
|
||||
authUrl = gitlabAuthUrl;
|
||||
} else if (loginMethod === LoginMethod.BITBUCKET) {
|
||||
authUrl = bitbucketAuthUrl;
|
||||
}
|
||||
|
||||
// If we have an auth URL, redirect to it
|
||||
if (authUrl) {
|
||||
@@ -68,5 +79,6 @@ export const useAutoLogin = () => {
|
||||
loginMethod,
|
||||
githubAuthUrl,
|
||||
gitlabAuthUrl,
|
||||
bitbucketAuthUrl,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -80,7 +80,7 @@ export enum I18nKey {
|
||||
ANALYTICS$CONFIRM_PREFERENCES = "ANALYTICS$CONFIRM_PREFERENCES",
|
||||
SETTINGS$SAVING = "SETTINGS$SAVING",
|
||||
SETTINGS$SAVE_CHANGES = "SETTINGS$SAVE_CHANGES",
|
||||
SETTINGS$NAV_GIT = "SETTINGS$NAV_GIT",
|
||||
SETTINGS$NAV_INTEGRATIONS = "SETTINGS$NAV_INTEGRATIONS",
|
||||
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
|
||||
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
|
||||
SETTINGS$NAV_SECRETS = "SETTINGS$NAV_SECRETS",
|
||||
@@ -170,10 +170,10 @@ export enum I18nKey {
|
||||
GITHUB$TOKEN_LINK_TEXT = "GITHUB$TOKEN_LINK_TEXT",
|
||||
GITHUB$INSTRUCTIONS_LINK_TEXT = "GITHUB$INSTRUCTIONS_LINK_TEXT",
|
||||
COMMON$HERE = "COMMON$HERE",
|
||||
ANALYTICS$ENABLE = "ANALYTICS$ENABLE",
|
||||
GITHUB$TOKEN_INVALID = "GITHUB$TOKEN_INVALID",
|
||||
BUTTON$DISCONNECT = "BUTTON$DISCONNECT",
|
||||
GITHUB$CONFIGURE_REPOS = "GITHUB$CONFIGURE_REPOS",
|
||||
SLACK$INSTALL_APP = "SLACK$INSTALL_APP",
|
||||
COMMON$CLICK_FOR_INSTRUCTIONS = "COMMON$CLICK_FOR_INSTRUCTIONS",
|
||||
LLM$SELECT_MODEL_PLACEHOLDER = "LLM$SELECT_MODEL_PLACEHOLDER",
|
||||
LLM$MODEL = "LLM$MODEL",
|
||||
@@ -248,6 +248,7 @@ export enum I18nKey {
|
||||
INVARIANT$TRACE_EXPORTED_MESSAGE = "INVARIANT$TRACE_EXPORTED_MESSAGE",
|
||||
INVARIANT$POLICY_UPDATED_MESSAGE = "INVARIANT$POLICY_UPDATED_MESSAGE",
|
||||
INVARIANT$SETTINGS_UPDATED_MESSAGE = "INVARIANT$SETTINGS_UPDATED_MESSAGE",
|
||||
CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE = "CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE",
|
||||
CHAT_INTERFACE$DISCONNECTED = "CHAT_INTERFACE$DISCONNECTED",
|
||||
CHAT_INTERFACE$CONNECTING = "CHAT_INTERFACE$CONNECTING",
|
||||
CHAT_INTERFACE$STOPPED = "CHAT_INTERFACE$STOPPED",
|
||||
@@ -508,6 +509,7 @@ export enum I18nKey {
|
||||
SETTINGS_FORM$BASE_URL = "SETTINGS_FORM$BASE_URL",
|
||||
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
|
||||
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
|
||||
BITBUCKET$CONNECT_TO_BITBUCKET = "BITBUCKET$CONNECT_TO_BITBUCKET",
|
||||
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
|
||||
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
|
||||
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
|
||||
@@ -524,6 +526,12 @@ export enum I18nKey {
|
||||
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
|
||||
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
|
||||
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
|
||||
BITBUCKET$TOKEN_LABEL = "BITBUCKET$TOKEN_LABEL",
|
||||
BITBUCKET$HOST_LABEL = "BITBUCKET$HOST_LABEL",
|
||||
BITBUCKET$GET_TOKEN = "BITBUCKET$GET_TOKEN",
|
||||
BITBUCKET$TOKEN_HELP_TEXT = "BITBUCKET$TOKEN_HELP_TEXT",
|
||||
BITBUCKET$TOKEN_LINK_TEXT = "BITBUCKET$TOKEN_LINK_TEXT",
|
||||
BITBUCKET$INSTRUCTIONS_LINK_TEXT = "BITBUCKET$INSTRUCTIONS_LINK_TEXT",
|
||||
GITLAB$OR_SEE = "GITLAB$OR_SEE",
|
||||
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
|
||||
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED",
|
||||
@@ -583,4 +591,13 @@ 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_AGENT_PERFORMANCE = "FEEDBACK$RATE_AGENT_PERFORMANCE",
|
||||
FEEDBACK$SELECT_REASON = "FEEDBACK$SELECT_REASON",
|
||||
FEEDBACK$SELECT_REASON_COUNTDOWN = "FEEDBACK$SELECT_REASON_COUNTDOWN",
|
||||
FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION = "FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION",
|
||||
FEEDBACK$REASON_FORGOT_CONTEXT = "FEEDBACK$REASON_FORGOT_CONTEXT",
|
||||
FEEDBACK$REASON_UNNECESSARY_CHANGES = "FEEDBACK$REASON_UNNECESSARY_CHANGES",
|
||||
FEEDBACK$REASON_OTHER = "FEEDBACK$REASON_OTHER",
|
||||
FEEDBACK$THANK_YOU_FOR_FEEDBACK = "FEEDBACK$THANK_YOU_FOR_FEEDBACK",
|
||||
FEEDBACK$FAILED_TO_SUBMIT = "FEEDBACK$FAILED_TO_SUBMIT",
|
||||
}
|
||||
|
||||
@@ -816,20 +816,20 @@
|
||||
"uk": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}"
|
||||
},
|
||||
"HOME$CONNECT_PROVIDER_MESSAGE": {
|
||||
"en": "To get started with suggested tasks, please connect your GitHub or GitLab account.",
|
||||
"ja": "提案されたタスクを始めるには、GitHubまたはGitLabアカウントを接続してください。",
|
||||
"zh-CN": "要开始使用建议的任务,请连接您的GitHub或GitLab账户。",
|
||||
"zh-TW": "要開始使用建議的任務,請連接您的GitHub或GitLab帳戶。",
|
||||
"ko-KR": "제안된 작업을 시작하려면 GitHub 또는 GitLab 계정을 연결하세요.",
|
||||
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub eller GitLab-kontoen din.",
|
||||
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub o GitLab.",
|
||||
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub ou GitLab.",
|
||||
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub o GitLab.",
|
||||
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab الخاص بك.",
|
||||
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub ou GitLab.",
|
||||
"tr": "Önerilen görevlerle başlamak için lütfen GitHub veya GitLab hesabınızı bağlayın.",
|
||||
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub- oder GitLab-Konto.",
|
||||
"uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub або GitLab."
|
||||
"en": "To get started with suggested tasks, please connect your GitHub, GitLab, or Bitbucket account.",
|
||||
"ja": "提案されたタスクを始めるには、GitHub、GitLab、またはBitbucketアカウントを接続してください。",
|
||||
"zh-CN": "要开始使用建议的任务,请连接您的GitHub、GitLab或Bitbucket账户。",
|
||||
"zh-TW": "要開始使用建議的任務,請連接您的GitHub、GitLab或Bitbucket帳戶。",
|
||||
"ko-KR": "제안된 작업을 시작하려면 GitHub, GitLab 또는 Bitbucket 계정을 연결하세요.",
|
||||
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub, GitLab eller Bitbucket-kontoen din.",
|
||||
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub, GitLab o Bitbucket.",
|
||||
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub, GitLab ou Bitbucket.",
|
||||
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub, GitLab o Bitbucket.",
|
||||
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab أو Bitbucket الخاص بك.",
|
||||
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub, GitLab ou Bitbucket.",
|
||||
"tr": "Önerilen görevlerle başlamak için lütfen GitHub, GitLab veya Bitbucket hesabınızı bağlayın.",
|
||||
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub-, GitLab- oder Bitbucket-Konto.",
|
||||
"uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub, GitLab або Bitbucket."
|
||||
},
|
||||
"HOME$LETS_START_BUILDING": {
|
||||
"en": "Let's Start Building!",
|
||||
@@ -1279,21 +1279,21 @@
|
||||
"de": "Änderungen speichern",
|
||||
"uk": "Зберегти зміни"
|
||||
},
|
||||
"SETTINGS$NAV_GIT": {
|
||||
"en": "Git",
|
||||
"ja": "Git",
|
||||
"zh-CN": "Git",
|
||||
"zh-TW": "Git",
|
||||
"ko-KR": "Git",
|
||||
"no": "Git",
|
||||
"it": "Git",
|
||||
"pt": "Git",
|
||||
"es": "Git",
|
||||
"ar": "Git",
|
||||
"fr": "Git",
|
||||
"tr": "Git",
|
||||
"de": "Git",
|
||||
"uk": "Git"
|
||||
"SETTINGS$NAV_INTEGRATIONS": {
|
||||
"en": "Integrations",
|
||||
"ja": "統合",
|
||||
"zh-CN": "集成",
|
||||
"zh-TW": "整合",
|
||||
"ko-KR": "통합",
|
||||
"no": "Integrasjoner",
|
||||
"it": "Integrazioni",
|
||||
"pt": "Integrações",
|
||||
"es": "Integraciones",
|
||||
"ar": "التكامل",
|
||||
"fr": "Intégrations",
|
||||
"tr": "Entegrasyonlar",
|
||||
"de": "Integrationen",
|
||||
"uk": "Інтеграції"
|
||||
},
|
||||
"SETTINGS$NAV_APPLICATION": {
|
||||
"en": "Application",
|
||||
@@ -2719,22 +2719,6 @@
|
||||
"de": "Hier",
|
||||
"uk": "тут"
|
||||
},
|
||||
"ANALYTICS$ENABLE": {
|
||||
"en": "Enable analytics",
|
||||
"ja": "アナリティクスを有効にする",
|
||||
"zh-CN": "启用分析",
|
||||
"zh-TW": "啟用分析功能",
|
||||
"ko-KR": "분석 활성화",
|
||||
"no": "Aktiver analyse",
|
||||
"it": "Abilita analisi",
|
||||
"pt": "Ativar análise",
|
||||
"es": "Habilitar análisis",
|
||||
"ar": "تمكين التحليلات",
|
||||
"fr": "Activer les analyses",
|
||||
"tr": "Analitiği etkinleştir",
|
||||
"de": "Analyse aktivieren",
|
||||
"uk": "Увімкнути аналітику"
|
||||
},
|
||||
"GITHUB$TOKEN_INVALID": {
|
||||
"en": "Invalid GitHub token",
|
||||
"ja": "GitHubトークンが無効です",
|
||||
@@ -2783,6 +2767,22 @@
|
||||
"de": "GitHub-Repositories konfigurieren",
|
||||
"uk": "Налаштування репозиторіїв Github"
|
||||
},
|
||||
"SLACK$INSTALL_APP": {
|
||||
"en": "Install OpenHands Slack App",
|
||||
"ja": "OpenHands Slackアプリをインストール",
|
||||
"zh-CN": "安装 OpenHands Slack 应用",
|
||||
"zh-TW": "安裝 OpenHands Slack 應用程式",
|
||||
"ko-KR": "OpenHands Slack 앱 설치",
|
||||
"no": "Installer OpenHands Slack-app",
|
||||
"it": "Installa l'app Slack di OpenHands",
|
||||
"pt": "Instalar aplicativo Slack do OpenHands",
|
||||
"es": "Instalar aplicación Slack de OpenHands",
|
||||
"ar": "تثبيت تطبيق OpenHands Slack",
|
||||
"fr": "Installer l'application Slack OpenHands",
|
||||
"tr": "OpenHands Slack uygulamasını yükle",
|
||||
"de": "OpenHands Slack-App installieren",
|
||||
"uk": "Встановити додаток OpenHands Slack"
|
||||
},
|
||||
"COMMON$CLICK_FOR_INSTRUCTIONS": {
|
||||
"en": "Click here for instructions",
|
||||
"ja": "手順はこちらをクリック",
|
||||
@@ -3967,6 +3967,22 @@
|
||||
"ja": "設定を更新しました",
|
||||
"uk": "Налаштування оновлено"
|
||||
},
|
||||
"CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE":{
|
||||
"en": "NEW FILES ADDED",
|
||||
"de": "NEUE DATEIEN HINZUGEFÜGT",
|
||||
"zh-CN": "已添加新文件",
|
||||
"zh-TW": "已新增檔案",
|
||||
"ko-KR": "새 파일이 추가되었습니다",
|
||||
"no": "NYE FILER LAGT TIL",
|
||||
"it": "NUOVI FILE AGGIUNTI",
|
||||
"pt": "NOVOS ARQUIVOS ADICIONADOS",
|
||||
"es": "NUEVOS ARCHIVOS AÑADIDOS",
|
||||
"ar": "تمت إضافة ملفات جديدة",
|
||||
"fr": "NOUVEAUX FICHIERS AJOUTÉS",
|
||||
"tr": "YENİ DOSYALAR EKLENDİ",
|
||||
"ja": "新しいファイルが追加されました",
|
||||
"uk": "ДОДАНО НОВІ ФАЙЛИ"
|
||||
},
|
||||
"CHAT_INTERFACE$DISCONNECTED": {
|
||||
"en": "Disconnected",
|
||||
"ja": "切断されました",
|
||||
@@ -8127,6 +8143,22 @@
|
||||
"tr": "GitLab'a bağlan",
|
||||
"uk": "Увійти за допомогою GitLab"
|
||||
},
|
||||
"BITBUCKET$CONNECT_TO_BITBUCKET": {
|
||||
"en": "Log in with Bitbucket",
|
||||
"ja": "Bitbucketに接続",
|
||||
"zh-CN": "连接到Bitbucket",
|
||||
"zh-TW": "連接到Bitbucket",
|
||||
"ko-KR": "Bitbucket에 연결",
|
||||
"de": "Mit Bitbucket verbinden",
|
||||
"no": "Koble til Bitbucket",
|
||||
"it": "Connetti a Bitbucket",
|
||||
"pt": "Conectar ao Bitbucket",
|
||||
"es": "Conectar a Bitbucket",
|
||||
"ar": "الاتصال بـ Bitbucket",
|
||||
"fr": "Se connecter à Bitbucket",
|
||||
"tr": "Bitbucket'a bağlan",
|
||||
"uk": "Увійти за допомогою Bitbucket"
|
||||
},
|
||||
"AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER": {
|
||||
"en": "Log in to OpenHands",
|
||||
"ja": "IDプロバイダーでサインイン",
|
||||
@@ -8383,6 +8415,102 @@
|
||||
"de": "klicken Sie hier für Anweisungen",
|
||||
"uk": "натисніть тут, щоб отримати інструкції"
|
||||
},
|
||||
"BITBUCKET$TOKEN_LABEL": {
|
||||
"en": "Bitbucket Token",
|
||||
"ja": "Bitbucketトークン",
|
||||
"zh-CN": "Bitbucket令牌",
|
||||
"zh-TW": "Bitbucket權杖",
|
||||
"ko-KR": "Bitbucket 토큰",
|
||||
"no": "Bitbucket-token",
|
||||
"it": "Token Bitbucket",
|
||||
"pt": "Token do Bitbucket",
|
||||
"es": "Token de Bitbucket",
|
||||
"ar": "رمز Bitbucket",
|
||||
"fr": "Jeton Bitbucket",
|
||||
"tr": "Bitbucket Token",
|
||||
"de": "Bitbucket-Token",
|
||||
"uk": "Токен Bitbucket"
|
||||
},
|
||||
"BITBUCKET$HOST_LABEL": {
|
||||
"en": "Bitbucket Host",
|
||||
"ja": "Bitbucketホスト",
|
||||
"zh-CN": "Bitbucket主机",
|
||||
"zh-TW": "Bitbucket主機",
|
||||
"ko-KR": "Bitbucket 호스트",
|
||||
"no": "Bitbucket-vert",
|
||||
"it": "Host Bitbucket",
|
||||
"pt": "Host do Bitbucket",
|
||||
"es": "Host de Bitbucket",
|
||||
"ar": "مضيف Bitbucket",
|
||||
"fr": "Hôte Bitbucket",
|
||||
"tr": "Bitbucket Sunucu",
|
||||
"de": "Bitbucket-Host",
|
||||
"uk": "Хост Bitbucket"
|
||||
},
|
||||
"BITBUCKET$GET_TOKEN": {
|
||||
"en": "Get a Bitbucket token",
|
||||
"ja": "Bitbucketトークンを取得",
|
||||
"zh-CN": "获取Bitbucket令牌",
|
||||
"zh-TW": "獲取Bitbucket權杖",
|
||||
"ko-KR": "Bitbucket 토큰 받기",
|
||||
"no": "Få et Bitbucket-token",
|
||||
"it": "Ottieni un token Bitbucket",
|
||||
"pt": "Obter um token do Bitbucket",
|
||||
"es": "Obtener un token de Bitbucket",
|
||||
"ar": "الحصول على رمز Bitbucket",
|
||||
"fr": "Obtenir un jeton Bitbucket",
|
||||
"tr": "Bitbucket token al",
|
||||
"de": "Bitbucket-Token erhalten",
|
||||
"uk": "Отримати токен Bitbucket"
|
||||
},
|
||||
"BITBUCKET$TOKEN_HELP_TEXT": {
|
||||
"en": "Get your <0>Bitbucket app password</0> or <1>click here for instructions</1>. Enter it in the format 'username:app_password'.",
|
||||
"ja": "<0>Bitbucketアプリパスワード</0>を取得するか、<1>手順についてはここをクリック</1>。'ユーザー名:アプリパスワード'の形式で入力してください。",
|
||||
"zh-CN": "获取您的<0>Bitbucket应用密码</0>或<1>点击此处获取说明</1>。请以'用户名:应用密码'的格式输入。",
|
||||
"zh-TW": "取得您的<0>Bitbucket應用密碼</0>或<1>點擊此處獲取說明</1>。請以'用戶名:應用密碼'的格式輸入。",
|
||||
"ko-KR": "<0>Bitbucket 앱 비밀번호</0>를 받거나 <1>지침을 보려면 여기를 클릭</1>하세요. '사용자 이름:앱 비밀번호' 형식으로 입력하세요.",
|
||||
"no": "Få ditt <0>Bitbucket app-passord</0> eller <1>klikk her for instruksjoner</1>. Skriv det inn i formatet 'brukernavn:app-passord'.",
|
||||
"it": "Ottieni la tua <0>password dell'app Bitbucket</0> o <1>clicca qui per istruzioni</1>. Inseriscila nel formato 'nome utente:password dell'app'.",
|
||||
"pt": "Obtenha sua <0>senha de aplicativo do Bitbucket</0> ou <1>clique aqui para instruções</1>. Digite-a no formato 'nome de usuário:senha do aplicativo'.",
|
||||
"es": "Obtenga su <0>contraseña de aplicación de Bitbucket</0> o <1>haga clic aquí para obtener instrucciones</1>. Ingrésela en el formato 'nombre de usuario:contraseña de aplicación'.",
|
||||
"ar": "احصل على <0>كلمة مرور تطبيق Bitbucket</0> الخاصة بك أو <1>انقر هنا للحصول على تعليمات</1>. أدخلها بتنسيق 'اسم المستخدم:كلمة مرور التطبيق'.",
|
||||
"fr": "Obtenez votre <0>mot de passe d'application Bitbucket</0> ou <1>cliquez ici pour les instructions</1>. Saisissez-le au format 'nom d'utilisateur:mot de passe d'application'.",
|
||||
"tr": "<0>Bitbucket uygulama şifrenizi</0> alın veya <1>talimatlar için buraya tıklayın</1>. 'kullanıcı adı:uygulama şifresi' formatında girin.",
|
||||
"de": "Holen Sie sich Ihr <0>Bitbucket App-Passwort</0> oder <1>klicken Sie hier für Anweisungen</1>. Geben Sie es im Format 'Benutzername:App-Passwort' ein.",
|
||||
"uk": "Отримайте свій <0>пароль додатка Bitbucket</0> або <1>натисніть тут, щоб отримати інструкції</1>. Введіть його у форматі 'ім'я користувача:пароль додатка'."
|
||||
},
|
||||
"BITBUCKET$TOKEN_LINK_TEXT": {
|
||||
"en": "Bitbucket app password",
|
||||
"ja": "Bitbucketアプリパスワード",
|
||||
"zh-CN": "Bitbucket应用密码",
|
||||
"zh-TW": "Bitbucket應用密碼",
|
||||
"ko-KR": "Bitbucket 앱 비밀번호",
|
||||
"no": "Bitbucket app-passord",
|
||||
"it": "password dell'app Bitbucket",
|
||||
"pt": "senha de aplicativo do Bitbucket",
|
||||
"es": "contraseña de aplicación de Bitbucket",
|
||||
"ar": "كلمة مرور تطبيق Bitbucket",
|
||||
"fr": "mot de passe d'application Bitbucket",
|
||||
"tr": "Bitbucket uygulama şifresi",
|
||||
"de": "Bitbucket App-Passwort",
|
||||
"uk": "пароль додатка Bitbucket"
|
||||
},
|
||||
"BITBUCKET$INSTRUCTIONS_LINK_TEXT": {
|
||||
"en": "click here for instructions",
|
||||
"ja": "手順についてはここをクリック",
|
||||
"zh-CN": "点击此处获取说明",
|
||||
"zh-TW": "點擊此處獲取說明",
|
||||
"ko-KR": "지침을 보려면 여기를 클릭",
|
||||
"no": "klikk her for instruksjoner",
|
||||
"it": "clicca qui per istruzioni",
|
||||
"pt": "clique aqui para instruções",
|
||||
"es": "haga clic aquí para obtener instrucciones",
|
||||
"ar": "انقر هنا للحصول على تعليمات",
|
||||
"fr": "cliquez ici pour les instructions",
|
||||
"tr": "talimatlar için buraya tıklayın",
|
||||
"de": "klicken Sie hier für Anweisungen",
|
||||
"uk": "натисніть тут, щоб отримати інструкції"
|
||||
},
|
||||
"GITLAB$OR_SEE": {
|
||||
"en": "or see the",
|
||||
"ja": "または参照",
|
||||
@@ -9326,5 +9454,149 @@
|
||||
"tr": "Doğrulama e-postası yeniden gönderilemedi",
|
||||
"de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden",
|
||||
"uk": "Не вдалося повторно надіслати лист підтвердження"
|
||||
},
|
||||
"FEEDBACK$RATE_AGENT_PERFORMANCE": {
|
||||
"en": "Rate the agent's performance:",
|
||||
"ja": "エージェントのパフォーマンスを評価してください:",
|
||||
"zh-CN": "评价代理的表现:",
|
||||
"zh-TW": "評價代理的表現:",
|
||||
"ko-KR": "에이전트의 성능을 평가하세요:",
|
||||
"no": "Vurder agentens ytelse:",
|
||||
"it": "Valuta le prestazioni dell'agente:",
|
||||
"pt": "Avalie o desempenho do agente:",
|
||||
"es": "Evalúe el rendimiento del agente:",
|
||||
"ar": "قيم أداء الوكيل:",
|
||||
"fr": "Évaluez la performance de l'agent :",
|
||||
"tr": "Ajanın performansını değerlendirin:",
|
||||
"de": "Bewerten Sie die Leistung des Agenten:",
|
||||
"uk": "Оцініть продуктивність агента:"
|
||||
},
|
||||
"FEEDBACK$SELECT_REASON": {
|
||||
"en": "Select a reason (optional):",
|
||||
"ja": "理由を選択してください(任意):",
|
||||
"zh-CN": "选择原因(可选):",
|
||||
"zh-TW": "選擇原因(可選):",
|
||||
"ko-KR": "이유 선택 (선택 사항):",
|
||||
"no": "Velg en grunn (valgfritt):",
|
||||
"it": "Seleziona un motivo (opzionale):",
|
||||
"pt": "Selecione um motivo (opcional):",
|
||||
"es": "Seleccione un motivo (opcional):",
|
||||
"ar": "حدد سببًا (اختياري):",
|
||||
"fr": "Sélectionnez une raison (facultatif) :",
|
||||
"tr": "Bir neden seçin (isteğe bağlı):",
|
||||
"de": "Wählen Sie einen Grund (optional):",
|
||||
"uk": "Виберіть причину (необов'язково):"
|
||||
},
|
||||
"FEEDBACK$SELECT_REASON_COUNTDOWN": {
|
||||
"en": "Auto-submitting in {{countdown}} seconds...",
|
||||
"ja": "{{countdown}}秒後に自動送信されます...",
|
||||
"zh-CN": "{{countdown}}秒后自动提交...",
|
||||
"zh-TW": "{{countdown}}秒後自動提交...",
|
||||
"ko-KR": "{{countdown}}초 후 자동 제출...",
|
||||
"no": "Sender automatisk om {{countdown}} sekunder...",
|
||||
"it": "Invio automatico tra {{countdown}} secondi...",
|
||||
"pt": "Enviando automaticamente em {{countdown}} segundos...",
|
||||
"es": "Enviando automáticamente en {{countdown}} segundos...",
|
||||
"ar": "الإرسال التلقائي خلال {{countdown}} ثانية...",
|
||||
"fr": "Envoi automatique dans {{countdown}} secondes...",
|
||||
"tr": "{{countdown}} saniye içinde otomatik gönderilecek...",
|
||||
"de": "Automatische Übermittlung in {{countdown}} Sekunden...",
|
||||
"uk": "Автоматична відправка через {{countdown}} секунд..."
|
||||
},
|
||||
"FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION": {
|
||||
"en": "The agent misunderstood my instruction",
|
||||
"ja": "エージェントは私の指示を誤解しました",
|
||||
"zh-CN": "代理误解了我的指示",
|
||||
"zh-TW": "代理誤解了我的指示",
|
||||
"ko-KR": "에이전트가 내 지시를 잘못 이해했습니다",
|
||||
"no": "Agenten misforsto instruksjonene mine",
|
||||
"it": "L'agente ha frainteso le mie istruzioni",
|
||||
"pt": "O agente não entendeu minhas instruções",
|
||||
"es": "El agente malinterpretó mis instrucciones",
|
||||
"ar": "أساء الوكيل فهم تعليماتي",
|
||||
"fr": "L'agent a mal compris mes instructions",
|
||||
"tr": "Ajan talimatlarımı yanlış anladı",
|
||||
"de": "Der Agent hat meine Anweisungen missverstanden",
|
||||
"uk": "Агент неправильно зрозумів мої інструкції"
|
||||
},
|
||||
"FEEDBACK$REASON_FORGOT_CONTEXT": {
|
||||
"en": "The agent forgot about the earlier context",
|
||||
"ja": "エージェントは以前のコンテキストを忘れました",
|
||||
"zh-CN": "代理忘记了之前的上下文",
|
||||
"zh-TW": "代理忘記了之前的上下文",
|
||||
"ko-KR": "에이전트가 이전 컨텍스트를 잊었습니다",
|
||||
"no": "Agenten glemte den tidligere konteksten",
|
||||
"it": "L'agente ha dimenticato il contesto precedente",
|
||||
"pt": "O agente esqueceu o contexto anterior",
|
||||
"es": "El agente olvidó el contexto anterior",
|
||||
"ar": "نسي الوكيل السياق السابق",
|
||||
"fr": "L'agent a oublié le contexte précédent",
|
||||
"tr": "Ajan önceki bağlamı unuttu",
|
||||
"de": "Der Agent hat den früheren Kontext vergessen",
|
||||
"uk": "Агент забув про попередній контекст"
|
||||
},
|
||||
"FEEDBACK$REASON_UNNECESSARY_CHANGES": {
|
||||
"en": "The agent made unnecessary changes",
|
||||
"ja": "エージェントは不要な変更を行いました",
|
||||
"zh-CN": "代理进行了不必要的更改",
|
||||
"zh-TW": "代理進行了不必要的更改",
|
||||
"ko-KR": "에이전트가 불필요한 변경을 했습니다",
|
||||
"no": "Agenten gjorde unødvendige endringer",
|
||||
"it": "L'agente ha apportato modifiche non necessarie",
|
||||
"pt": "O agente fez alterações desnecessárias",
|
||||
"es": "El agente hizo cambios innecesarios",
|
||||
"ar": "قام الوكيل بتغييرات غير ضرورية",
|
||||
"fr": "L'agent a apporté des modifications inutiles",
|
||||
"tr": "Ajan gereksiz değişiklikler yaptı",
|
||||
"de": "Der Agent hat unnötige Änderungen vorgenommen",
|
||||
"uk": "Агент зробив непотрібні зміни"
|
||||
},
|
||||
"FEEDBACK$REASON_OTHER": {
|
||||
"en": "Other",
|
||||
"ja": "その他",
|
||||
"zh-CN": "其他",
|
||||
"zh-TW": "其他",
|
||||
"ko-KR": "기타",
|
||||
"no": "Annet",
|
||||
"it": "Altro",
|
||||
"pt": "Outro",
|
||||
"es": "Otro",
|
||||
"ar": "أخرى",
|
||||
"fr": "Autre",
|
||||
"tr": "Diğer",
|
||||
"de": "Andere",
|
||||
"uk": "Інше"
|
||||
},
|
||||
"FEEDBACK$THANK_YOU_FOR_FEEDBACK": {
|
||||
"en": "Thank you for your feedback! This will help us improve OpenHands going forward.",
|
||||
"ja": "フィードバックをありがとうございます!これにより、今後OpenHandsを改善していくことができます。",
|
||||
"zh-CN": "感谢您的反馈!这将帮助我们改进OpenHands。",
|
||||
"zh-TW": "感謝您的反饋!這將幫助我們改進OpenHands。",
|
||||
"ko-KR": "피드백 감사합니다! 이를 통해 OpenHands를 개선해 나가겠습니다.",
|
||||
"no": "Takk for tilbakemeldingen! Dette vil hjelpe oss med å forbedre OpenHands fremover.",
|
||||
"it": "Grazie per il tuo feedback! Questo ci aiuterà a migliorare OpenHands in futuro.",
|
||||
"pt": "Obrigado pelo seu feedback! Isso nos ajudará a melhorar o OpenHands no futuro.",
|
||||
"es": "¡Gracias por su comentario! Esto nos ayudará a mejorar OpenHands en el futuro.",
|
||||
"ar": "شكرا على ملاحظاتك! سيساعدنا هذا في تحسين OpenHands في المستقبل.",
|
||||
"fr": "Merci pour votre retour ! Cela nous aidera à améliorer OpenHands à l'avenir.",
|
||||
"tr": "Geri bildiriminiz için teşekkürler! Bu, OpenHands'i ileride geliştirmemize yardımcı olacak.",
|
||||
"de": "Vielen Dank für Ihr Feedback! Das hilft uns, OpenHands in Zukunft zu verbessern.",
|
||||
"uk": "Дякуємо за ваш відгук! Це допоможе нам покращити OpenHands у майбутньому."
|
||||
},
|
||||
"FEEDBACK$FAILED_TO_SUBMIT": {
|
||||
"en": "Failed to submit feedback",
|
||||
"ja": "フィードバックの送信に失敗しました",
|
||||
"zh-CN": "提交反馈失败",
|
||||
"zh-TW": "提交反饋失敗",
|
||||
"ko-KR": "피드백 제출 실패",
|
||||
"no": "Kunne ikke sende tilbakemelding",
|
||||
"it": "Impossibile inviare feedback",
|
||||
"pt": "Falha ao enviar feedback",
|
||||
"es": "Error al enviar comentarios",
|
||||
"ar": "فشل في تقديم التعليقات",
|
||||
"fr": "Échec de l'envoi des commentaires",
|
||||
"tr": "Geri bildirim gönderilemedi",
|
||||
"de": "Feedback konnte nicht gesendet werden",
|
||||
"uk": "Не вдалося надіслати відгук"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,13 +140,13 @@ export const handlers = [
|
||||
http.get("/api/user/repositories", () => {
|
||||
const data: GitRepository[] = [
|
||||
{
|
||||
id: 1,
|
||||
id: "1",
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: "2",
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -157,7 +157,7 @@ export const handlers = [
|
||||
}),
|
||||
http.get("/api/user/info", () => {
|
||||
const user: GitUser = {
|
||||
id: 1,
|
||||
id: "1",
|
||||
login: "octocat",
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
|
||||
company: "GitHub",
|
||||
|
||||
@@ -19,7 +19,6 @@ const chat = ws.link(`ws://${window?.location.host}/socket.io`);
|
||||
|
||||
export const handlers: WebSocketHandler[] = [
|
||||
chat.addEventListener("connection", (connection) => {
|
||||
// @ts-expect-error - MSW v2 type incompatibility
|
||||
const io = toSocketIo(connection);
|
||||
// @ts-expect-error - accessing private property for testing purposes
|
||||
const { url }: { url: URL } = io.client.connection;
|
||||
|
||||
@@ -31,6 +31,7 @@ export const generateAssistantMessageAction = (
|
||||
args: {
|
||||
thought: message,
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
wait_for_response: false,
|
||||
},
|
||||
});
|
||||
@@ -46,6 +47,7 @@ export const generateUserMessageAction = (
|
||||
args: {
|
||||
content: message,
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export default [
|
||||
index("routes/llm-settings.tsx"),
|
||||
route("mcp", "routes/mcp-settings.tsx"),
|
||||
route("user", "routes/user-settings.tsx"),
|
||||
route("git", "routes/git-settings.tsx"),
|
||||
route("integrations", "routes/git-settings.tsx"),
|
||||
route("app", "routes/app-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
route("secrets", "routes/secrets-settings.tsx"),
|
||||
|
||||
@@ -139,7 +139,7 @@ function AppSettingsScreen() {
|
||||
defaultIsToggled={!!settings.USER_CONSENTS_TO_ANALYTICS}
|
||||
onToggle={checkIfAnalyticsSwitchHasChanged}
|
||||
>
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)}
|
||||
</SettingsSwitch>
|
||||
|
||||
<SettingsSwitch
|
||||
|
||||
@@ -6,7 +6,9 @@ import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
|
||||
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
|
||||
import { BitbucketTokenInput } from "#/components/features/settings/git-settings/bitbucket-token-input";
|
||||
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
|
||||
import { InstallSlackAppAnchor } from "#/components/features/settings/git-settings/install-slack-app-anchor";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
@@ -32,18 +34,24 @@ function GitSettingsScreen() {
|
||||
React.useState(false);
|
||||
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
const [bitbucketTokenInputHasValue, setBitbucketTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const [githubHostInputHasValue, setGithubHostInputHasValue] =
|
||||
React.useState(false);
|
||||
const [gitlabHostInputHasValue, setGitlabHostInputHasValue] =
|
||||
React.useState(false);
|
||||
const [bitbucketHostInputHasValue, setBitbucketHostInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const existingGithubHost = settings?.PROVIDER_TOKENS_SET.github;
|
||||
const existingGitlabHost = settings?.PROVIDER_TOKENS_SET.gitlab;
|
||||
const existingBitbucketHost = settings?.PROVIDER_TOKENS_SET.bitbucket;
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const isGitHubTokenSet = providers.includes("github");
|
||||
const isGitLabTokenSet = providers.includes("gitlab");
|
||||
const isBitbucketTokenSet = providers.includes("bitbucket");
|
||||
|
||||
const formAction = async (formData: FormData) => {
|
||||
const disconnectButtonClicked =
|
||||
@@ -56,15 +64,23 @@ function GitSettingsScreen() {
|
||||
|
||||
const githubToken = formData.get("github-token-input")?.toString() || "";
|
||||
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
|
||||
const bitbucketToken =
|
||||
formData.get("bitbucket-token-input")?.toString() || "";
|
||||
const githubHost = formData.get("github-host-input")?.toString() || "";
|
||||
const gitlabHost = formData.get("gitlab-host-input")?.toString() || "";
|
||||
const bitbucketHost =
|
||||
formData.get("bitbucket-host-input")?.toString() || "";
|
||||
|
||||
// Create providers object with all tokens
|
||||
const providerTokens: Record<string, { token: string; host: string }> = {
|
||||
github: { token: githubToken, host: githubHost },
|
||||
gitlab: { token: gitlabToken, host: gitlabHost },
|
||||
bitbucket: { token: bitbucketToken, host: bitbucketHost },
|
||||
};
|
||||
|
||||
saveGitProviders(
|
||||
{
|
||||
providers: {
|
||||
github: { token: githubToken, host: githubHost },
|
||||
gitlab: { token: gitlabToken, host: gitlabHost },
|
||||
},
|
||||
providers: providerTokens,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -77,8 +93,10 @@ function GitSettingsScreen() {
|
||||
onSettled: () => {
|
||||
setGithubTokenInputHasValue(false);
|
||||
setGitlabTokenInputHasValue(false);
|
||||
setBitbucketTokenInputHasValue(false);
|
||||
setGithubHostInputHasValue(false);
|
||||
setGitlabHostInputHasValue(false);
|
||||
setBitbucketHostInputHasValue(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -87,8 +105,10 @@ function GitSettingsScreen() {
|
||||
const formIsClean =
|
||||
!githubTokenInputHasValue &&
|
||||
!gitlabTokenInputHasValue &&
|
||||
!bitbucketTokenInputHasValue &&
|
||||
!githubHostInputHasValue &&
|
||||
!gitlabHostInputHasValue;
|
||||
!gitlabHostInputHasValue &&
|
||||
!bitbucketHostInputHasValue;
|
||||
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
|
||||
|
||||
return (
|
||||
@@ -103,6 +123,10 @@ function GitSettingsScreen() {
|
||||
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
|
||||
)}
|
||||
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<InstallSlackAppAnchor />
|
||||
)}
|
||||
|
||||
{!isSaas && (
|
||||
<GitHubTokenInput
|
||||
name="github-token-input"
|
||||
@@ -111,7 +135,7 @@ function GitSettingsScreen() {
|
||||
setGithubTokenInputHasValue(!!value);
|
||||
}}
|
||||
onGitHubHostChange={(value) => {
|
||||
setGitlabHostInputHasValue(!!value);
|
||||
setGithubHostInputHasValue(!!value);
|
||||
}}
|
||||
githubHostSet={existingGithubHost}
|
||||
/>
|
||||
@@ -130,6 +154,20 @@ function GitSettingsScreen() {
|
||||
gitlabHostSet={existingGitlabHost}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSaas && (
|
||||
<BitbucketTokenInput
|
||||
name="bitbucket-token-input"
|
||||
isBitbucketTokenSet={isBitbucketTokenSet}
|
||||
onChange={(value) => {
|
||||
setBitbucketTokenInputHasValue(!!value);
|
||||
}}
|
||||
onBitbucketHostChange={(value) => {
|
||||
setBitbucketHostInputHasValue(!!value);
|
||||
}}
|
||||
bitbucketHostSet={existingBitbucketHost}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -143,7 +181,9 @@ function GitSettingsScreen() {
|
||||
name="disconnect-tokens-button"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
|
||||
isDisabled={
|
||||
!isGitHubTokenSet && !isGitLabTokenSet && !isBitbucketTokenSet
|
||||
}
|
||||
>
|
||||
Disconnect Tokens
|
||||
</BrandButton>
|
||||
|
||||
@@ -84,7 +84,11 @@ function SecretsSettingsScreen() {
|
||||
)}
|
||||
|
||||
{shouldRenderConnectToGitButton && (
|
||||
<Link to="/settings/git" data-testid="connect-git-button" type="button">
|
||||
<Link
|
||||
to="/settings/integrations"
|
||||
data-testid="connect-git-button"
|
||||
type="button"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
Connect a Git provider to manage secrets
|
||||
</BrandButton>
|
||||
|
||||
@@ -16,7 +16,7 @@ function SettingsScreen() {
|
||||
|
||||
const saasNavItems = [
|
||||
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/integrations", text: t("SETTINGS$NAV_INTEGRATIONS") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
|
||||
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
|
||||
@@ -26,7 +26,7 @@ function SettingsScreen() {
|
||||
const ossNavItems = [
|
||||
{ to: "/settings", text: t("SETTINGS$NAV_LLM") },
|
||||
{ to: "/settings/mcp", text: t("SETTINGS$NAV_MCP") },
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/integrations", text: t("SETTINGS$NAV_INTEGRATIONS") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
|
||||
];
|
||||
|
||||
@@ -3,11 +3,12 @@ import ActionType from "#/types/action-type";
|
||||
export function createChatMessage(
|
||||
message: string,
|
||||
image_urls: string[],
|
||||
file_urls: string[],
|
||||
timestamp: string,
|
||||
) {
|
||||
const event = {
|
||||
action: ActionType.MESSAGE,
|
||||
args: { content: message, image_urls, timestamp },
|
||||
args: { content: message, image_urls, file_urls, timestamp },
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
|
||||
args: {
|
||||
content: string;
|
||||
image_urls: string[];
|
||||
file_urls: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +37,7 @@ export interface AssistantMessageAction
|
||||
args: {
|
||||
thought: string;
|
||||
image_urls: string[] | null;
|
||||
file_urls: string[];
|
||||
wait_for_response: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
4
frontend/src/types/git.d.ts
vendored
4
frontend/src/types/git.d.ts
vendored
@@ -7,7 +7,7 @@ interface GitHubErrorReponse {
|
||||
}
|
||||
|
||||
interface GitUser {
|
||||
id: number;
|
||||
id: string;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
company: string | null;
|
||||
@@ -23,7 +23,7 @@ interface Branch {
|
||||
}
|
||||
|
||||
interface GitRepository {
|
||||
id: number;
|
||||
id: string;
|
||||
full_name: string;
|
||||
git_provider: Provider;
|
||||
is_public: boolean;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const ProviderOptions = {
|
||||
github: "github",
|
||||
gitlab: "gitlab",
|
||||
bitbucket: "bitbucket",
|
||||
} as const;
|
||||
|
||||
export type Provider = keyof typeof ProviderOptions;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Generates a URL to redirect to for OAuth authentication
|
||||
* @param identityProvider The identity provider to use (e.g., "github", "gitlab")
|
||||
* @param identityProvider The identity provider to use (e.g., "github", "gitlab", "bitbucket")
|
||||
* @param requestUrl The URL of the request
|
||||
* @returns The URL to redirect to for OAuth
|
||||
*/
|
||||
|
||||
7
frontend/src/utils/is-file-image.ts
Normal file
7
frontend/src/utils/is-file-image.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Check if a file is an image.
|
||||
* @param file - The File object to check.
|
||||
* @returns True if the file is an image, false otherwise.
|
||||
*/
|
||||
export const isFileImage = (file: File): boolean =>
|
||||
file.type.startsWith("image/");
|
||||
@@ -7,11 +7,12 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
export enum LoginMethod {
|
||||
GITHUB = "github",
|
||||
GITLAB = "gitlab",
|
||||
BITBUCKET = "bitbucket",
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the login method in local storage
|
||||
* @param method The login method (github or gitlab)
|
||||
* @param method The login method (github, gitlab, or bitbucket)
|
||||
*/
|
||||
export const setLoginMethod = (method: LoginMethod): void => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, method);
|
||||
|
||||
34
microagents/bitbucket.md
Normal file
34
microagents/bitbucket.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: bitbucket
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- bitbucket
|
||||
---
|
||||
|
||||
You have access to an environment variable, `BITBUCKET_TOKEN`, which allows you to interact with
|
||||
the Bitbucket API.
|
||||
|
||||
<IMPORTANT>
|
||||
You can use `curl` with the `BITBUCKET_TOKEN` to interact with Bitbucket's API.
|
||||
ALWAYS use the Bitbucket API for operations instead of a web browser.
|
||||
ALWAYS use the `create_bitbucket_pr` tool to open a pull request
|
||||
</IMPORTANT>
|
||||
|
||||
If you encounter authentication issues when pushing to Bitbucket (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://x-token-auth:${BITBUCKET_TOKEN}@bitbucket.org/username/repo.git`
|
||||
|
||||
Here are some instructions for pushing, but ONLY do this if the user asks you to:
|
||||
* NEVER push directly to the `main` or `master` branch
|
||||
* Git config (username and email) is pre-set. Do not modify.
|
||||
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
|
||||
* Use the `create_bitbucket_pr` tool to create a pull request, if you haven't already
|
||||
* Once you've created your own branch or a pull request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
|
||||
* Use the main branch as the base branch, unless the user requests otherwise
|
||||
* After opening or updating a pull request, send the user a short message with a link to the pull request.
|
||||
* Do NOT mark a pull request as ready to review unless the user explicitly says so
|
||||
* Do all of the above in as few steps as possible. E.g. you could push changes with one step by running the following bash commands:
|
||||
```bash
|
||||
git remote -v && git branch # to find the current org, repo and branch
|
||||
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
|
||||
```
|
||||
@@ -125,9 +125,9 @@ class BrowsingAgent(Agent):
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Resets the Browsing Agent."""
|
||||
"""Resets the Browsing Agent's internal state."""
|
||||
super().reset()
|
||||
self.cost_accumulator = 0
|
||||
# Reset agent-specific counters but not LLM metrics
|
||||
self.error_accumulator = 0
|
||||
|
||||
def step(self, state: State) -> Action:
|
||||
|
||||
@@ -136,8 +136,9 @@ class CodeActAgent(Agent):
|
||||
return tools
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Resets the CodeAct Agent."""
|
||||
"""Resets the CodeAct Agent's internal state."""
|
||||
super().reset()
|
||||
# Only clear pending actions, not LLM metrics
|
||||
self.pending_actions.clear()
|
||||
|
||||
def step(self, state: State) -> 'Action':
|
||||
|
||||
@@ -119,14 +119,14 @@ class DummyAgent(Agent):
|
||||
]
|
||||
|
||||
def step(self, state: State) -> Action:
|
||||
if state.iteration >= len(self.steps):
|
||||
if state.iteration_flag.current_value >= len(self.steps):
|
||||
return AgentFinishAction()
|
||||
|
||||
current_step = self.steps[state.iteration]
|
||||
current_step = self.steps[state.iteration_flag.current_value]
|
||||
action = current_step['action']
|
||||
|
||||
if state.iteration > 0:
|
||||
prev_step = self.steps[state.iteration - 1]
|
||||
if state.iteration_flag.current_value > 0:
|
||||
prev_step = self.steps[state.iteration_flag.current_value - 1]
|
||||
|
||||
if 'observations' in prev_step and prev_step['observations']:
|
||||
expected_observations = prev_step['observations']
|
||||
|
||||
@@ -176,9 +176,9 @@ Note:
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Resets the VisualBrowsingAgent."""
|
||||
"""Resets the VisualBrowsingAgent's internal state."""
|
||||
super().reset()
|
||||
self.cost_accumulator = 0
|
||||
# Reset agent-specific counters but not LLM metrics
|
||||
self.error_accumulator = 0
|
||||
|
||||
def step(self, state: State) -> Action:
|
||||
|
||||
@@ -8,6 +8,7 @@ from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.shortcuts import clear
|
||||
|
||||
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
|
||||
import openhands.cli.suppress_warnings # noqa: F401
|
||||
from openhands.cli.commands import (
|
||||
check_folder_security_agreement,
|
||||
handle_commands,
|
||||
@@ -273,9 +274,9 @@ async def run_session(
|
||||
)
|
||||
)
|
||||
|
||||
config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
runtime.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
|
||||
await add_mcp_tools_to_agent(agent, runtime, memory, config)
|
||||
await add_mcp_tools_to_agent(agent, runtime, memory)
|
||||
|
||||
# Clear loading animation
|
||||
is_loaded.set()
|
||||
|
||||
10
openhands/cli/suppress_warnings.py
Normal file
10
openhands/cli/suppress_warnings.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Module to suppress common warnings."""
|
||||
|
||||
import warnings
|
||||
|
||||
# Suppress pydub warning about ffmpeg/avconv
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
message="Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work",
|
||||
category=RuntimeWarning,
|
||||
)
|
||||
@@ -103,16 +103,10 @@ class Agent(ABC):
|
||||
pass
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Resets the agent's execution status and clears the history. This method can be used
|
||||
to prepare the agent for restarting the instruction or cleaning up before destruction.
|
||||
|
||||
"""
|
||||
# TODO clear history
|
||||
"""Resets the agent's execution status."""
|
||||
# Only reset the completion status, not the LLM metrics
|
||||
self._complete = False
|
||||
|
||||
if self.llm:
|
||||
self.llm.reset()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.__class__.__name__
|
||||
|
||||
@@ -7,7 +7,6 @@ import time
|
||||
import traceback
|
||||
from typing import Callable
|
||||
|
||||
import litellm # noqa
|
||||
from litellm.exceptions import ( # noqa
|
||||
APIConnectionError,
|
||||
APIError,
|
||||
@@ -25,7 +24,8 @@ from litellm.exceptions import ( # noqa
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.replay import ReplayManager
|
||||
from openhands.controller.state.state import State, TrafficControlState
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.controller.state.state_tracker import StateTracker
|
||||
from openhands.controller.stuck import StuckDetector
|
||||
from openhands.core.config import AgentConfig, LLMConfig
|
||||
from openhands.core.exceptions import (
|
||||
@@ -61,7 +61,6 @@ from openhands.events.action import (
|
||||
)
|
||||
from openhands.events.action.agent import CondensationAction, RecallAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.observation import (
|
||||
AgentDelegateObservation,
|
||||
AgentStateChangedObservation,
|
||||
@@ -69,10 +68,11 @@ from openhands.events.observation import (
|
||||
NullObservation,
|
||||
Observation,
|
||||
)
|
||||
from openhands.events.serialization.event import event_to_trajectory, truncate_content
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.metrics import Metrics, TokenUsage
|
||||
from openhands.memory.view import View
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
# note: RESUME is only available on web GUI
|
||||
TRAFFIC_CONTROL_REMINDER = (
|
||||
@@ -101,11 +101,13 @@ class AgentController:
|
||||
self,
|
||||
agent: Agent,
|
||||
event_stream: EventStream,
|
||||
max_iterations: int,
|
||||
max_budget_per_task: float | None = None,
|
||||
iteration_delta: int,
|
||||
budget_per_task_delta: float | None = None,
|
||||
agent_to_llm_config: dict[str, LLMConfig] | None = None,
|
||||
agent_configs: dict[str, AgentConfig] | None = None,
|
||||
sid: str | None = None,
|
||||
file_store: FileStore | None = None,
|
||||
user_id: str | None = None,
|
||||
confirmation_mode: bool = False,
|
||||
initial_state: State | None = None,
|
||||
is_delegate: bool = False,
|
||||
@@ -132,7 +134,10 @@ class AgentController:
|
||||
status_callback: Optional callback function to handle status updates.
|
||||
replay_events: A list of logs to replay.
|
||||
"""
|
||||
|
||||
self.id = sid or event_stream.sid
|
||||
self.user_id = user_id
|
||||
self.file_store = file_store
|
||||
self.agent = agent
|
||||
self.headless_mode = headless_mode
|
||||
self.is_delegate = is_delegate
|
||||
@@ -146,29 +151,22 @@ class AgentController:
|
||||
EventStreamSubscriber.AGENT_CONTROLLER, self.on_event, self.id
|
||||
)
|
||||
|
||||
# filter out events that are not relevant to the agent
|
||||
# so they will not be included in the agent history
|
||||
self.agent_history_filter = EventFilter(
|
||||
exclude_types=(
|
||||
NullAction,
|
||||
NullObservation,
|
||||
ChangeAgentStateAction,
|
||||
AgentStateChangedObservation,
|
||||
),
|
||||
exclude_hidden=True,
|
||||
)
|
||||
self.state_tracker = StateTracker(sid, file_store, user_id)
|
||||
|
||||
# state from the previous session, state from a parent agent, or a fresh state
|
||||
self.set_initial_state(
|
||||
state=initial_state,
|
||||
max_iterations=max_iterations,
|
||||
max_iterations=iteration_delta,
|
||||
max_budget_per_task=budget_per_task_delta,
|
||||
confirmation_mode=confirmation_mode,
|
||||
)
|
||||
self.max_budget_per_task = max_budget_per_task
|
||||
|
||||
self.state = self.state_tracker.state # TODO: share between manager and controller for backward compatability; we should ideally move all state related logic to the state manager
|
||||
|
||||
self.agent_to_llm_config = agent_to_llm_config if agent_to_llm_config else {}
|
||||
self.agent_configs = agent_configs if agent_configs else {}
|
||||
self._initial_max_iterations = max_iterations
|
||||
self._initial_max_budget_per_task = max_budget_per_task
|
||||
self._initial_max_iterations = iteration_delta
|
||||
self._initial_max_budget_per_task = budget_per_task_delta
|
||||
|
||||
# stuck helper
|
||||
self._stuck_detector = StuckDetector(self.state)
|
||||
@@ -181,7 +179,7 @@ class AgentController:
|
||||
self._add_system_message()
|
||||
|
||||
def _add_system_message(self):
|
||||
for event in self.event_stream.get_events(start_id=self.state.start_id):
|
||||
for event in self.event_stream.search_events(start_id=self.state.start_id):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
# FIXME: Remove this after 6/1/2025
|
||||
# Do not try to add a system message if we first run into
|
||||
@@ -214,26 +212,7 @@ class AgentController:
|
||||
if set_stop_state:
|
||||
await self.set_agent_state_to(AgentState.STOPPED)
|
||||
|
||||
# we made history, now is the time to rewrite it!
|
||||
# the final state.history will be used by external scripts like evals, tests, etc.
|
||||
# history will need to be complete WITH delegates events
|
||||
# like the regular agent history, it does not include:
|
||||
# - 'hidden' events, events with hidden=True
|
||||
# - backend events (the default 'filtered out' types, types in self.filter_out)
|
||||
start_id = self.state.start_id if self.state.start_id >= 0 else 0
|
||||
end_id = (
|
||||
self.state.end_id
|
||||
if self.state.end_id >= 0
|
||||
else self.event_stream.get_latest_event_id()
|
||||
)
|
||||
self.state.history = list(
|
||||
self.event_stream.search_events(
|
||||
start_id=start_id,
|
||||
end_id=end_id,
|
||||
reverse=False,
|
||||
filter=self.agent_history_filter,
|
||||
)
|
||||
)
|
||||
self.state_tracker.close(self.event_stream)
|
||||
|
||||
# unsubscribe from the event stream
|
||||
# only the root parent controller subscribes to the event stream
|
||||
@@ -257,14 +236,6 @@ class AgentController:
|
||||
extra_merged = {'session_id': self.id, **extra}
|
||||
getattr(logger, level)(message, extra=extra_merged, stacklevel=2)
|
||||
|
||||
def update_state_before_step(self) -> None:
|
||||
self.state.iteration += 1
|
||||
self.state.local_iteration += 1
|
||||
|
||||
async def update_state_after_step(self) -> None:
|
||||
# update metrics especially for cost. Use deepcopy to avoid it being modified by agent._reset()
|
||||
self.state.local_metrics = copy.deepcopy(self.agent.llm.metrics)
|
||||
|
||||
async def _react_to_exception(
|
||||
self,
|
||||
e: Exception,
|
||||
@@ -390,10 +361,17 @@ class AgentController:
|
||||
# If we have a delegate that is not finished or errored, forward events to it
|
||||
if self.delegate is not None:
|
||||
delegate_state = self.delegate.get_agent_state()
|
||||
if delegate_state not in (
|
||||
AgentState.FINISHED,
|
||||
AgentState.ERROR,
|
||||
AgentState.REJECTED,
|
||||
if (
|
||||
delegate_state
|
||||
not in (
|
||||
AgentState.FINISHED,
|
||||
AgentState.ERROR,
|
||||
AgentState.REJECTED,
|
||||
)
|
||||
or 'RuntimeError: Agent reached maximum iteration.'
|
||||
in self.delegate.state.last_error
|
||||
or 'RuntimeError:Agent reached maximum budget for conversation'
|
||||
in self.delegate.state.last_error
|
||||
):
|
||||
# Forward the event to delegate and skip parent processing
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
@@ -412,9 +390,7 @@ class AgentController:
|
||||
if hasattr(event, 'hidden') and event.hidden:
|
||||
return
|
||||
|
||||
# if the event is not filtered out, add it to the history
|
||||
if self.agent_history_filter.include(event):
|
||||
self.state.history.append(event)
|
||||
self.state_tracker.add_history(event)
|
||||
|
||||
if isinstance(event, Action):
|
||||
await self._handle_action(event)
|
||||
@@ -457,11 +433,9 @@ class AgentController:
|
||||
|
||||
elif isinstance(action, AgentFinishAction):
|
||||
self.state.outputs = action.outputs
|
||||
self.state.metrics.merge(self.state.local_metrics)
|
||||
await self.set_agent_state_to(AgentState.FINISHED)
|
||||
elif isinstance(action, AgentRejectAction):
|
||||
self.state.outputs = action.outputs
|
||||
self.state.metrics.merge(self.state.local_metrics)
|
||||
await self.set_agent_state_to(AgentState.REJECTED)
|
||||
|
||||
async def _handle_observation(self, observation: Observation) -> None:
|
||||
@@ -481,8 +455,10 @@ class AgentController:
|
||||
log_level, str(observation_to_print), extra={'msg_type': 'OBSERVATION'}
|
||||
)
|
||||
|
||||
# TODO: these metrics come from the draft editor, and they get accumulated into controller's state metrics and the agent's llm metrics
|
||||
# In the future, we should have a more principled way to sharing metrics across all LLM instances for a given conversation
|
||||
if observation.llm_metrics is not None:
|
||||
self.agent.llm.metrics.merge(observation.llm_metrics)
|
||||
self.state_tracker.merge_metrics(observation.llm_metrics)
|
||||
|
||||
# this happens for runnable actions and microagent actions
|
||||
if self._pending_action and self._pending_action.id == observation.cause:
|
||||
@@ -496,9 +472,6 @@ class AgentController:
|
||||
if self.state.agent_state == AgentState.USER_REJECTED:
|
||||
await self.set_agent_state_to(AgentState.AWAITING_USER_INPUT)
|
||||
return
|
||||
elif isinstance(observation, ErrorObservation):
|
||||
if self.state.agent_state == AgentState.ERROR:
|
||||
self.state.metrics.merge(self.state.local_metrics)
|
||||
|
||||
async def _handle_message_action(self, action: MessageAction) -> None:
|
||||
"""Handles message actions from the event stream.
|
||||
@@ -516,22 +489,6 @@ class AgentController:
|
||||
str(action),
|
||||
extra={'msg_type': 'ACTION', 'event_source': EventSource.USER},
|
||||
)
|
||||
# Extend max iterations when the user sends a message (only in non-headless mode)
|
||||
if self._initial_max_iterations is not None and not self.headless_mode:
|
||||
self.state.max_iterations = (
|
||||
self.state.iteration + self._initial_max_iterations
|
||||
)
|
||||
if (
|
||||
self.state.traffic_control_state == TrafficControlState.THROTTLING
|
||||
or self.state.traffic_control_state == TrafficControlState.PAUSED
|
||||
):
|
||||
self.state.traffic_control_state = TrafficControlState.NORMAL
|
||||
self.log(
|
||||
'debug',
|
||||
f'Extended max iterations to {self.state.max_iterations} after user message',
|
||||
)
|
||||
# try to retrieve microagents relevant to the user message
|
||||
# set pending_action while we search for information
|
||||
|
||||
# if this is the first user message for this agent, matters for the microagent info type
|
||||
first_user_message = self._first_user_message()
|
||||
@@ -605,36 +562,16 @@ class AgentController:
|
||||
return
|
||||
|
||||
if new_state in (AgentState.STOPPED, AgentState.ERROR):
|
||||
# sync existing metrics BEFORE resetting the agent
|
||||
await self.update_state_after_step()
|
||||
self.state.metrics.merge(self.state.local_metrics)
|
||||
self._reset()
|
||||
elif (
|
||||
new_state == AgentState.RUNNING
|
||||
and self.state.agent_state == AgentState.PAUSED
|
||||
# TODO: do we really need both THROTTLING and PAUSED states, or can we clean up one of them completely?
|
||||
and self.state.traffic_control_state == TrafficControlState.THROTTLING
|
||||
):
|
||||
# user intends to interrupt traffic control and let the task resume temporarily
|
||||
self.state.traffic_control_state = TrafficControlState.PAUSED
|
||||
# User has chosen to deliberately continue - lets double the max iterations
|
||||
if (
|
||||
self.state.iteration is not None
|
||||
and self.state.max_iterations is not None
|
||||
and self._initial_max_iterations is not None
|
||||
and not self.headless_mode
|
||||
):
|
||||
if self.state.iteration >= self.state.max_iterations:
|
||||
self.state.max_iterations += self._initial_max_iterations
|
||||
|
||||
if (
|
||||
self.state.metrics.accumulated_cost is not None
|
||||
and self.max_budget_per_task is not None
|
||||
and self._initial_max_budget_per_task is not None
|
||||
):
|
||||
if self.state.metrics.accumulated_cost >= self.max_budget_per_task:
|
||||
self.max_budget_per_task += self._initial_max_budget_per_task
|
||||
elif self._pending_action is not None and (
|
||||
# User is allowing to check control limits and expand them if applicable
|
||||
if (
|
||||
self.state.agent_state == AgentState.ERROR
|
||||
and new_state == AgentState.RUNNING
|
||||
):
|
||||
self.state_tracker.maybe_increase_control_flags_limits(self.headless_mode)
|
||||
|
||||
if self._pending_action is not None and (
|
||||
new_state in (AgentState.USER_CONFIRMED, AgentState.USER_REJECTED)
|
||||
):
|
||||
if hasattr(self._pending_action, 'thought'):
|
||||
@@ -659,6 +596,10 @@ class AgentController:
|
||||
EventSource.ENVIRONMENT,
|
||||
)
|
||||
|
||||
# Save state whenever agent state changes to ensure we don't lose state
|
||||
# in case of crashes or unexpected circumstances
|
||||
self.save_state()
|
||||
|
||||
def get_agent_state(self) -> AgentState:
|
||||
"""Returns the current state of the agent.
|
||||
|
||||
@@ -686,19 +627,27 @@ class AgentController:
|
||||
agent_cls: type[Agent] = Agent.get_cls(action.agent)
|
||||
agent_config = self.agent_configs.get(action.agent, self.agent.config)
|
||||
llm_config = self.agent_to_llm_config.get(action.agent, self.agent.llm.config)
|
||||
llm = LLM(config=llm_config, retry_listener=self._notify_on_llm_retry)
|
||||
# Make sure metrics are shared between parent and child for global accumulation
|
||||
llm = LLM(
|
||||
config=llm_config,
|
||||
retry_listener=self.agent.llm.retry_listener,
|
||||
metrics=self.state.metrics,
|
||||
)
|
||||
delegate_agent = agent_cls(llm=llm, config=agent_config)
|
||||
|
||||
# Take a snapshot of the current metrics before starting the delegate
|
||||
state = State(
|
||||
session_id=self.id.removesuffix('-delegate'),
|
||||
inputs=action.inputs or {},
|
||||
local_iteration=0,
|
||||
iteration=self.state.iteration,
|
||||
max_iterations=self.state.max_iterations,
|
||||
iteration_flag=self.state.iteration_flag,
|
||||
budget_flag=self.state.budget_flag,
|
||||
delegate_level=self.state.delegate_level + 1,
|
||||
# global metrics should be shared between parent and child
|
||||
metrics=self.state.metrics,
|
||||
# start on top of the stream
|
||||
start_id=self.event_stream.get_latest_event_id() + 1,
|
||||
parent_metrics_snapshot=self.state_tracker.get_metrics_snapshot(),
|
||||
parent_iteration=self.state.iteration_flag.current_value,
|
||||
)
|
||||
self.log(
|
||||
'debug',
|
||||
@@ -708,10 +657,12 @@ class AgentController:
|
||||
# Create the delegate with is_delegate=True so it does NOT subscribe directly
|
||||
self.delegate = AgentController(
|
||||
sid=self.id + '-delegate',
|
||||
file_store=self.file_store,
|
||||
user_id=self.user_id,
|
||||
agent=delegate_agent,
|
||||
event_stream=self.event_stream,
|
||||
max_iterations=self.state.max_iterations,
|
||||
max_budget_per_task=self.max_budget_per_task,
|
||||
iteration_delta=self._initial_max_iterations,
|
||||
budget_per_task_delta=self._initial_max_budget_per_task,
|
||||
agent_to_llm_config=self.agent_to_llm_config,
|
||||
agent_configs=self.agent_configs,
|
||||
initial_state=state,
|
||||
@@ -730,7 +681,13 @@ class AgentController:
|
||||
delegate_state = self.delegate.get_agent_state()
|
||||
|
||||
# update iteration that is shared across agents
|
||||
self.state.iteration = self.delegate.state.iteration
|
||||
self.state.iteration_flag.current_value = (
|
||||
self.delegate.state.iteration_flag.current_value
|
||||
)
|
||||
|
||||
# Calculate delegate-specific metrics before closing the delegate
|
||||
delegate_metrics = self.state.get_local_metrics()
|
||||
logger.info(f'Local metrics for delegate: {delegate_metrics}')
|
||||
|
||||
# close the delegate controller before adding new events
|
||||
asyncio.get_event_loop().run_until_complete(self.delegate.close())
|
||||
@@ -743,8 +700,12 @@ class AgentController:
|
||||
|
||||
# prepare delegate result observation
|
||||
# TODO: replace this with AI-generated summary (#2395)
|
||||
# Filter out metrics from the formatted output to avoid clutter
|
||||
display_outputs = {
|
||||
k: v for k, v in delegate_outputs.items() if k != 'metrics'
|
||||
}
|
||||
formatted_output = ', '.join(
|
||||
f'{key}: {value}' for key, value in delegate_outputs.items()
|
||||
f'{key}: {value}' for key, value in display_outputs.items()
|
||||
)
|
||||
content = (
|
||||
f'{self.delegate.agent.name} finishes task with {formatted_output}'
|
||||
@@ -798,24 +759,16 @@ class AgentController:
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'LEVEL {self.state.delegate_level} LOCAL STEP {self.state.local_iteration} GLOBAL STEP {self.state.iteration}',
|
||||
f'LEVEL {self.state.delegate_level} LOCAL STEP {self.state.get_local_step()} GLOBAL STEP {self.state.iteration_flag.current_value}',
|
||||
extra={'msg_type': 'STEP'},
|
||||
)
|
||||
|
||||
stop_step = False
|
||||
if self.state.iteration >= self.state.max_iterations:
|
||||
stop_step = await self._handle_traffic_control(
|
||||
'iteration', self.state.iteration, self.state.max_iterations
|
||||
)
|
||||
if self.max_budget_per_task is not None:
|
||||
current_cost = self.state.metrics.accumulated_cost
|
||||
if current_cost > self.max_budget_per_task:
|
||||
stop_step = await self._handle_traffic_control(
|
||||
'budget', current_cost, self.max_budget_per_task
|
||||
)
|
||||
if stop_step:
|
||||
logger.warning('Stopping agent due to traffic control')
|
||||
return
|
||||
# Ensure budget control flag is synchronized with the latest metrics.
|
||||
# In the future, we should centralized the use of one LLM object per conversation.
|
||||
# This will help us unify the cost for auto generating titles, running the condensor, etc.
|
||||
# Before many microservices will touh the same llm cost field, we should sync with the budget flag for the controller
|
||||
# and check that we haven't exceeded budget BEFORE executing an agent step.
|
||||
self.state_tracker.sync_budget_flag_with_metrics()
|
||||
|
||||
if self._is_stuck():
|
||||
await self._react_to_exception(
|
||||
@@ -823,7 +776,13 @@ class AgentController:
|
||||
)
|
||||
return
|
||||
|
||||
self.update_state_before_step()
|
||||
try:
|
||||
self.state_tracker.run_control_flags()
|
||||
except Exception as e:
|
||||
logger.warning('Control flag limits hit')
|
||||
await self._react_to_exception(e)
|
||||
return
|
||||
|
||||
action: Action = NullAction()
|
||||
|
||||
if self._replay_manager.should_replay():
|
||||
@@ -894,60 +853,9 @@ class AgentController:
|
||||
|
||||
self.event_stream.add_event(action, action._source) # type: ignore [attr-defined]
|
||||
|
||||
await self.update_state_after_step()
|
||||
|
||||
log_level = 'info' if LOG_ALL_EVENTS else 'debug'
|
||||
self.log(log_level, str(action), extra={'msg_type': 'ACTION'})
|
||||
|
||||
def _notify_on_llm_retry(self, retries: int, max: int) -> None:
|
||||
if self.status_callback is not None:
|
||||
msg_id = 'STATUS$LLM_RETRY'
|
||||
self.status_callback(
|
||||
'info', msg_id, f'Retrying LLM request, {retries} / {max}'
|
||||
)
|
||||
|
||||
async def _handle_traffic_control(
|
||||
self, limit_type: str, current_value: float, max_value: float
|
||||
) -> bool:
|
||||
"""Handles agent state after hitting the traffic control limit.
|
||||
|
||||
Args:
|
||||
limit_type (str): The type of limit that was hit.
|
||||
current_value (float): The current value of the limit.
|
||||
max_value (float): The maximum value of the limit.
|
||||
"""
|
||||
stop_step = False
|
||||
if self.state.traffic_control_state == TrafficControlState.PAUSED:
|
||||
self.log(
|
||||
'debug', 'Hitting traffic control, temporarily resume upon user request'
|
||||
)
|
||||
self.state.traffic_control_state = TrafficControlState.NORMAL
|
||||
else:
|
||||
self.state.traffic_control_state = TrafficControlState.THROTTLING
|
||||
# Format values as integers for iterations, keep decimals for budget
|
||||
if limit_type == 'iteration':
|
||||
current_str = str(int(current_value))
|
||||
max_str = str(int(max_value))
|
||||
else:
|
||||
current_str = f'{current_value:.2f}'
|
||||
max_str = f'{max_value:.2f}'
|
||||
|
||||
if self.headless_mode:
|
||||
e = RuntimeError(
|
||||
f'Agent reached maximum {limit_type} in headless mode. '
|
||||
f'Current {limit_type}: {current_str}, max {limit_type}: {max_str}'
|
||||
)
|
||||
await self._react_to_exception(e)
|
||||
else:
|
||||
e = RuntimeError(
|
||||
f'Agent reached maximum {limit_type}. '
|
||||
f'Current {limit_type}: {current_str}, max {limit_type}: {max_str}. '
|
||||
)
|
||||
# FIXME: this isn't really an exception--we should have a different path
|
||||
await self._react_to_exception(e)
|
||||
stop_step = True
|
||||
return stop_step
|
||||
|
||||
@property
|
||||
def _pending_action(self) -> Action | None:
|
||||
"""Get the current pending action with time tracking.
|
||||
@@ -1015,150 +923,26 @@ class AgentController:
|
||||
self,
|
||||
state: State | None,
|
||||
max_iterations: int,
|
||||
max_budget_per_task: float | None,
|
||||
confirmation_mode: bool = False,
|
||||
) -> None:
|
||||
"""Sets the initial state for the agent, either from the previous session, or from a parent agent, or by creating a new one.
|
||||
|
||||
Args:
|
||||
state: The state to initialize with, or None to create a new state.
|
||||
max_iterations: The maximum number of iterations allowed for the task.
|
||||
confirmation_mode: Whether to enable confirmation mode.
|
||||
"""
|
||||
# state can come from:
|
||||
# - the previous session, in which case it has history
|
||||
# - from a parent agent, in which case it has no history
|
||||
# - None / a new state
|
||||
|
||||
# If state is None, we create a brand new state and still load the event stream so we can restore the history
|
||||
if state is None:
|
||||
self.state = State(
|
||||
session_id=self.id.removesuffix('-delegate'),
|
||||
inputs={},
|
||||
max_iterations=max_iterations,
|
||||
confirmation_mode=confirmation_mode,
|
||||
)
|
||||
self.state.start_id = 0
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
f'AgentController {self.id} - created new state. start_id: {self.state.start_id}',
|
||||
)
|
||||
else:
|
||||
self.state = state
|
||||
|
||||
if self.state.start_id <= -1:
|
||||
self.state.start_id = 0
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
f'AgentController {self.id} initializing history from event {self.state.start_id}',
|
||||
)
|
||||
|
||||
):
|
||||
self.state_tracker.set_initial_state(
|
||||
self.id,
|
||||
self.agent,
|
||||
state,
|
||||
max_iterations,
|
||||
max_budget_per_task,
|
||||
confirmation_mode,
|
||||
)
|
||||
# Always load from the event stream to avoid losing history
|
||||
self._init_history()
|
||||
self.state_tracker._init_history(
|
||||
self.event_stream,
|
||||
)
|
||||
|
||||
def get_trajectory(self, include_screenshots: bool = False) -> list[dict]:
|
||||
# state history could be partially hidden/truncated before controller is closed
|
||||
assert self._closed
|
||||
return [
|
||||
event_to_trajectory(event, include_screenshots)
|
||||
for event in self.state.history
|
||||
]
|
||||
|
||||
def _init_history(self) -> None:
|
||||
"""Initializes the agent's history from the event stream.
|
||||
|
||||
The history is a list of events that:
|
||||
- Excludes events of types listed in self.filter_out
|
||||
- Excludes events with hidden=True attribute
|
||||
- For delegate events (between AgentDelegateAction and AgentDelegateObservation):
|
||||
- Excludes all events between the action and observation
|
||||
- Includes the delegate action and observation themselves
|
||||
"""
|
||||
# define range of events to fetch
|
||||
# delegates start with a start_id and initially won't find any events
|
||||
# otherwise we're restoring a previous session
|
||||
start_id = self.state.start_id if self.state.start_id >= 0 else 0
|
||||
end_id = (
|
||||
self.state.end_id
|
||||
if self.state.end_id >= 0
|
||||
else self.event_stream.get_latest_event_id()
|
||||
)
|
||||
|
||||
# sanity check
|
||||
if start_id > end_id + 1:
|
||||
self.log(
|
||||
'warning',
|
||||
f'start_id {start_id} is greater than end_id + 1 ({end_id + 1}). History will be empty.',
|
||||
)
|
||||
self.state.history = []
|
||||
return
|
||||
|
||||
events: list[Event] = []
|
||||
|
||||
# Get rest of history
|
||||
events_to_add = list(
|
||||
self.event_stream.search_events(
|
||||
start_id=start_id,
|
||||
end_id=end_id,
|
||||
reverse=False,
|
||||
filter=self.agent_history_filter,
|
||||
)
|
||||
)
|
||||
events.extend(events_to_add)
|
||||
|
||||
# Find all delegate action/observation pairs
|
||||
delegate_ranges: list[tuple[int, int]] = []
|
||||
delegate_action_ids: list[int] = [] # stack of unmatched delegate action IDs
|
||||
|
||||
for event in events:
|
||||
if isinstance(event, AgentDelegateAction):
|
||||
delegate_action_ids.append(event.id)
|
||||
# Note: we can get agent=event.agent and task=event.inputs.get('task','')
|
||||
# if we need to track these in the future
|
||||
|
||||
elif isinstance(event, AgentDelegateObservation):
|
||||
# Match with most recent unmatched delegate action
|
||||
if not delegate_action_ids:
|
||||
self.log(
|
||||
'warning',
|
||||
f'Found AgentDelegateObservation without matching action at id={event.id}',
|
||||
)
|
||||
continue
|
||||
|
||||
action_id = delegate_action_ids.pop()
|
||||
delegate_ranges.append((action_id, event.id))
|
||||
|
||||
# Filter out events between delegate action/observation pairs
|
||||
if delegate_ranges:
|
||||
filtered_events: list[Event] = []
|
||||
current_idx = 0
|
||||
|
||||
for start_id, end_id in sorted(delegate_ranges):
|
||||
# Add events before delegate range
|
||||
filtered_events.extend(
|
||||
event for event in events[current_idx:] if event.id < start_id
|
||||
)
|
||||
|
||||
# Add delegate action and observation
|
||||
filtered_events.extend(
|
||||
event for event in events if event.id in (start_id, end_id)
|
||||
)
|
||||
|
||||
# Update index to after delegate range
|
||||
current_idx = next(
|
||||
(i for i, e in enumerate(events) if e.id > end_id), len(events)
|
||||
)
|
||||
|
||||
# Add any remaining events after last delegate range
|
||||
filtered_events.extend(events[current_idx:])
|
||||
|
||||
self.state.history = filtered_events
|
||||
else:
|
||||
self.state.history = events
|
||||
|
||||
# make sure history is in sync
|
||||
self.state.start_id = start_id
|
||||
return self.state_tracker.get_trajectory(include_screenshots)
|
||||
|
||||
def _handle_long_context_error(self) -> None:
|
||||
# When context window is exceeded, keep roughly half of agent interactions
|
||||
@@ -1359,7 +1143,7 @@ class AgentController:
|
||||
action: The action to attach metrics to
|
||||
"""
|
||||
# Get metrics from agent LLM
|
||||
agent_metrics = self.agent.llm.metrics
|
||||
agent_metrics = self.state.metrics
|
||||
|
||||
# Get metrics from condenser LLM if it exists
|
||||
condenser_metrics: TokenUsage | None = None
|
||||
@@ -1390,10 +1174,10 @@ class AgentController:
|
||||
# Log the metrics information for debugging
|
||||
# Get the latest usage directly from the agent's metrics
|
||||
latest_usage = None
|
||||
if self.agent.llm.metrics.token_usages:
|
||||
latest_usage = self.agent.llm.metrics.token_usages[-1]
|
||||
if self.state.metrics.token_usages:
|
||||
latest_usage = self.state.metrics.token_usages[-1]
|
||||
|
||||
accumulated_usage = self.agent.llm.metrics.accumulated_token_usage
|
||||
accumulated_usage = self.state.metrics.accumulated_token_usage
|
||||
self.log(
|
||||
'debug',
|
||||
f'Action metrics - accumulated_cost: {metrics.accumulated_cost}, '
|
||||
@@ -1432,7 +1216,7 @@ class AgentController:
|
||||
)
|
||||
|
||||
def _is_awaiting_observation(self) -> bool:
|
||||
events = self.event_stream.get_events(reverse=True)
|
||||
events = self.event_stream.search_events(reverse=True)
|
||||
for event in events:
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
result = event.agent_state == AgentState.RUNNING
|
||||
@@ -1473,7 +1257,7 @@ class AgentController:
|
||||
self._cached_first_user_message = next(
|
||||
(
|
||||
e
|
||||
for e in self.event_stream.get_events(
|
||||
for e in self.event_stream.search_events(
|
||||
start_id=self.state.start_id,
|
||||
)
|
||||
if isinstance(e, MessageAction) and e.source == EventSource.USER
|
||||
@@ -1481,3 +1265,6 @@ class AgentController:
|
||||
None,
|
||||
)
|
||||
return self._cached_first_user_message
|
||||
|
||||
def save_state(self):
|
||||
self.state_tracker.save_state()
|
||||
|
||||
95
openhands/controller/state/control_flags.py
Normal file
95
openhands/controller/state/control_flags.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar(
|
||||
'T', int, float
|
||||
) # Type for the value (int for iterations, float for budget)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlFlag(Generic[T]):
|
||||
"""Base class for control flags that manage limits and state transitions."""
|
||||
|
||||
limit_increase_amount: T
|
||||
current_value: T
|
||||
max_value: T
|
||||
headless_mode: bool = False
|
||||
_hit_limit: bool = False
|
||||
|
||||
def reached_limit(self) -> bool:
|
||||
"""Check if the limit has been reached.
|
||||
|
||||
Returns:
|
||||
bool: True if the limit has been reached, False otherwise.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def increase_limit(self, headless_mode: bool) -> None:
|
||||
"""Expand the limit when needed."""
|
||||
raise NotImplementedError
|
||||
|
||||
def step(self):
|
||||
"""Determine the next state based on the current state and mode.
|
||||
|
||||
Returns:
|
||||
ControlFlagState: The next state.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass
|
||||
class IterationControlFlag(ControlFlag[int]):
|
||||
"""Control flag for managing iteration limits."""
|
||||
|
||||
def reached_limit(self) -> bool:
|
||||
"""Check if the iteration limit has been reached."""
|
||||
self._hit_limit = self.current_value >= self.max_value
|
||||
return self._hit_limit
|
||||
|
||||
def increase_limit(self, headless_mode: bool) -> None:
|
||||
"""Expand the iteration limit by adding the initial value."""
|
||||
if not headless_mode and self._hit_limit:
|
||||
self.max_value += self.limit_increase_amount
|
||||
self._hit_limit = False
|
||||
|
||||
def step(self):
|
||||
if self.reached_limit():
|
||||
raise RuntimeError(
|
||||
f'Agent reached maximum iteration. '
|
||||
f'Current iteration: {self.current_value}, max iteration: {self.max_value}'
|
||||
)
|
||||
|
||||
# Increment the current value
|
||||
self.current_value += 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class BudgetControlFlag(ControlFlag[float]):
|
||||
"""Control flag for managing budget limits."""
|
||||
|
||||
def reached_limit(self) -> bool:
|
||||
"""Check if the budget limit has been reached."""
|
||||
self._hit_limit = self.current_value >= self.max_value
|
||||
return self._hit_limit
|
||||
|
||||
def increase_limit(self, headless_mode) -> None:
|
||||
"""Expand the budget limit by adding the initial value to the current value."""
|
||||
if self._hit_limit:
|
||||
self.max_value = self.current_value + self.limit_increase_amount
|
||||
self._hit_limit = False
|
||||
|
||||
def step(self):
|
||||
"""Check if we've reached the limit and update state accordingly.
|
||||
|
||||
Note: Unlike IterationControlFlag, this doesn't increment the value
|
||||
as the budget is updated externally.
|
||||
"""
|
||||
if self.reached_limit():
|
||||
current_str = f'{self.current_value:.2f}'
|
||||
max_str = f'{self.max_value:.2f}'
|
||||
raise RuntimeError(
|
||||
f'Agent reached maximum budget for conversation.'
|
||||
f'Current budget: {current_str}, max budget: {max_str}'
|
||||
)
|
||||
@@ -8,6 +8,10 @@ from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import openhands
|
||||
from openhands.controller.state.control_flags import (
|
||||
BudgetControlFlag,
|
||||
IterationControlFlag,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events.action import (
|
||||
@@ -20,7 +24,15 @@ from openhands.memory.view import View
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import get_conversation_agent_state_filename
|
||||
|
||||
RESUMABLE_STATES = [
|
||||
AgentState.RUNNING,
|
||||
AgentState.PAUSED,
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
]
|
||||
|
||||
|
||||
# NOTE: this is deprecated
|
||||
class TrafficControlState(str, Enum):
|
||||
# default state, no rate limiting
|
||||
NORMAL = 'normal'
|
||||
@@ -32,14 +44,6 @@ class TrafficControlState(str, Enum):
|
||||
PAUSED = 'paused'
|
||||
|
||||
|
||||
RESUMABLE_STATES = [
|
||||
AgentState.RUNNING,
|
||||
AgentState.PAUSED,
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
"""
|
||||
@@ -75,35 +79,43 @@ class State:
|
||||
"""
|
||||
|
||||
session_id: str = ''
|
||||
# global iteration for the current task
|
||||
iteration: int = 0
|
||||
# local iteration for the current subtask
|
||||
local_iteration: int = 0
|
||||
# max number of iterations for the current task
|
||||
max_iterations: int = 100
|
||||
iteration_flag: IterationControlFlag = field(
|
||||
default_factory=lambda: IterationControlFlag(
|
||||
limit_increase_amount=100, current_value=0, max_value=100
|
||||
)
|
||||
)
|
||||
budget_flag: BudgetControlFlag | None = None
|
||||
confirmation_mode: bool = False
|
||||
history: list[Event] = field(default_factory=list)
|
||||
inputs: dict = field(default_factory=dict)
|
||||
outputs: dict = field(default_factory=dict)
|
||||
agent_state: AgentState = AgentState.LOADING
|
||||
resume_state: AgentState | None = None
|
||||
traffic_control_state: TrafficControlState = TrafficControlState.NORMAL
|
||||
# global metrics for the current task
|
||||
metrics: Metrics = field(default_factory=Metrics)
|
||||
# local metrics for the current subtask
|
||||
local_metrics: Metrics = field(default_factory=Metrics)
|
||||
# root agent has level 0, and every delegate increases the level by one
|
||||
delegate_level: int = 0
|
||||
# start_id and end_id track the range of events in history
|
||||
start_id: int = -1
|
||||
end_id: int = -1
|
||||
|
||||
delegates: dict[tuple[int, int], tuple[str, str]] = field(default_factory=dict)
|
||||
# NOTE: This will never be used by the controller, but it can be used by different
|
||||
parent_metrics_snapshot: Metrics | None = None
|
||||
parent_iteration: int = 100
|
||||
|
||||
# NOTE: this is used by the controller to track parent's metrics snapshot before delegation
|
||||
# evaluation tasks to store extra data needed to track the progress/state of the task.
|
||||
extra_data: dict[str, Any] = field(default_factory=dict)
|
||||
last_error: str = ''
|
||||
|
||||
# NOTE: deprecated args, kept here temporarily for backwards compatability
|
||||
# Will be remove in 30 days
|
||||
iteration: int | None = None
|
||||
local_iteration: int | None = None
|
||||
max_iterations: int | None = None
|
||||
traffic_control_state: TrafficControlState | None = None
|
||||
local_metrics: Metrics | None = None
|
||||
delegates: dict[tuple[int, int], tuple[str, str]] | None = None
|
||||
|
||||
def save_to_session(
|
||||
self, sid: str, file_store: FileStore, user_id: str | None
|
||||
) -> None:
|
||||
@@ -165,6 +177,10 @@ class State:
|
||||
|
||||
# first state after restore
|
||||
state.agent_state = AgentState.LOADING
|
||||
|
||||
# We don't need to clean up deprecated fields here
|
||||
# They will be handled by __getstate__ when the state is saved again
|
||||
|
||||
return state
|
||||
|
||||
def __getstate__(self) -> dict:
|
||||
@@ -177,15 +193,52 @@ class State:
|
||||
state.pop('_history_checksum', None)
|
||||
state.pop('_view', None)
|
||||
|
||||
# Remove deprecated fields before pickling
|
||||
state.pop('iteration', None)
|
||||
state.pop('local_iteration', None)
|
||||
state.pop('max_iterations', None)
|
||||
state.pop('traffic_control_state', None)
|
||||
state.pop('local_metrics', None)
|
||||
state.pop('delegates', None)
|
||||
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: dict) -> None:
|
||||
# Check if we're restoring from an older version (before control flags)
|
||||
is_old_version = 'iteration' in state
|
||||
|
||||
# Convert old iteration tracking to new iteration_flag if needed
|
||||
if is_old_version:
|
||||
# Create iteration_flag from old values
|
||||
max_iterations = state.get('max_iterations', 100)
|
||||
current_iteration = state.get('iteration', 0)
|
||||
|
||||
# Add the iteration_flag to the state
|
||||
state['iteration_flag'] = IterationControlFlag(
|
||||
limit_increase_amount=max_iterations,
|
||||
current_value=current_iteration,
|
||||
max_value=max_iterations,
|
||||
)
|
||||
|
||||
# Update the state
|
||||
self.__dict__.update(state)
|
||||
|
||||
# We keep the deprecated fields for backward compatibility
|
||||
# They will be removed by __getstate__ when the state is saved again
|
||||
|
||||
# make sure we always have the attribute history
|
||||
if not hasattr(self, 'history'):
|
||||
self.history = []
|
||||
|
||||
# Ensure we have default values for new fields if they're missing
|
||||
if not hasattr(self, 'iteration_flag'):
|
||||
self.iteration_flag = IterationControlFlag(
|
||||
limit_increase_amount=100, current_value=0, max_value=100
|
||||
)
|
||||
|
||||
if not hasattr(self, 'budget_flag'):
|
||||
self.budget_flag = None
|
||||
|
||||
def get_current_user_intent(self) -> tuple[str | None, list[str] | None]:
|
||||
"""Returns the latest user message and image(if provided) that appears after a FinishAction, or the first (the task) if nothing was finished yet."""
|
||||
last_user_message = None
|
||||
@@ -223,6 +276,17 @@ class State:
|
||||
],
|
||||
}
|
||||
|
||||
def get_local_step(self):
|
||||
if not self.parent_iteration:
|
||||
return self.iteration_flag.current_value
|
||||
|
||||
return self.iteration_flag.current_value - self.parent_iteration
|
||||
|
||||
def get_local_metrics(self):
|
||||
if not self.parent_metrics_snapshot:
|
||||
return self.metrics
|
||||
return self.metrics.diff(self.parent_metrics_snapshot)
|
||||
|
||||
@property
|
||||
def view(self) -> View:
|
||||
# Compute a simple checksum from the history to see if we can re-use any
|
||||
|
||||
290
openhands/controller/state/state_tracker.py
Normal file
290
openhands/controller/state/state_tracker.py
Normal file
@@ -0,0 +1,290 @@
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.state.control_flags import (
|
||||
BudgetControlFlag,
|
||||
IterationControlFlag,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.agent import AgentDelegateAction, ChangeAgentStateAction
|
||||
from openhands.events.action.empty import NullAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.observation.delegate import AgentDelegateObservation
|
||||
from openhands.events.observation.empty import NullObservation
|
||||
from openhands.events.serialization.event import event_to_trajectory
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
|
||||
class StateTracker:
|
||||
"""Manages and synchronizes the state of an agent throughout its lifecycle.
|
||||
|
||||
It is responsible for:
|
||||
1. Maintaining agent state persistence across sessions
|
||||
2. Managing agent history by filtering and tracking relevant events (previously done in the agent controller)
|
||||
3. Synchronizing metrics between the controller and LLM components
|
||||
4. Updating control flags for budget and iteration limits
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, sid: str | None, file_store: FileStore | None, user_id: str | None
|
||||
):
|
||||
self.sid = sid
|
||||
self.file_store = file_store
|
||||
self.user_id = user_id
|
||||
|
||||
# filter out events that are not relevant to the agent
|
||||
# so they will not be included in the agent history
|
||||
self.agent_history_filter = EventFilter(
|
||||
exclude_types=(
|
||||
NullAction,
|
||||
NullObservation,
|
||||
ChangeAgentStateAction,
|
||||
AgentStateChangedObservation,
|
||||
),
|
||||
exclude_hidden=True,
|
||||
)
|
||||
|
||||
def set_initial_state(
|
||||
self,
|
||||
id: str,
|
||||
agent: Agent,
|
||||
state: State | None,
|
||||
max_iterations: int,
|
||||
max_budget_per_task: float | None,
|
||||
confirmation_mode: bool = False,
|
||||
) -> None:
|
||||
"""Sets the initial state for the agent, either from the previous session, or from a parent agent, or by creating a new one.
|
||||
|
||||
Args:
|
||||
state: The state to initialize with, or None to create a new state.
|
||||
max_iterations: The maximum number of iterations allowed for the task.
|
||||
confirmation_mode: Whether to enable confirmation mode.
|
||||
"""
|
||||
# state can come from:
|
||||
# - the previous session, in which case it has history
|
||||
# - from a parent agent, in which case it has no history
|
||||
# - None / a new state
|
||||
|
||||
# If state is None, we create a brand new state and still load the event stream so we can restore the history
|
||||
if state is None:
|
||||
self.state = State(
|
||||
session_id=id.removesuffix('-delegate'),
|
||||
inputs={},
|
||||
iteration_flag=IterationControlFlag(
|
||||
limit_increase_amount=max_iterations,
|
||||
current_value=0,
|
||||
max_value=max_iterations,
|
||||
),
|
||||
budget_flag=None
|
||||
if not max_budget_per_task
|
||||
else BudgetControlFlag(
|
||||
limit_increase_amount=max_budget_per_task,
|
||||
current_value=0,
|
||||
max_value=max_budget_per_task,
|
||||
),
|
||||
confirmation_mode=confirmation_mode,
|
||||
)
|
||||
self.state.start_id = 0
|
||||
|
||||
logger.info(
|
||||
f'AgentController {id} - created new state. start_id: {self.state.start_id}'
|
||||
)
|
||||
else:
|
||||
self.state = state
|
||||
if self.state.start_id <= -1:
|
||||
self.state.start_id = 0
|
||||
|
||||
logger.info(
|
||||
f'AgentController {id} initializing history from event {self.state.start_id}',
|
||||
)
|
||||
|
||||
# Share the state metrics with the agent's LLM metrics
|
||||
# This ensures that all accumulated metrics are always in sync between controller and llm
|
||||
agent.llm.metrics = self.state.metrics
|
||||
|
||||
def _init_history(self, event_stream: EventStream) -> None:
|
||||
"""Initializes the agent's history from the event stream.
|
||||
|
||||
The history is a list of events that:
|
||||
- Excludes events of types listed in self.filter_out
|
||||
- Excludes events with hidden=True attribute
|
||||
- For delegate events (between AgentDelegateAction and AgentDelegateObservation):
|
||||
- Excludes all events between the action and observation
|
||||
- Includes the delegate action and observation themselves
|
||||
"""
|
||||
# define range of events to fetch
|
||||
# delegates start with a start_id and initially won't find any events
|
||||
# otherwise we're restoring a previous session
|
||||
start_id = self.state.start_id if self.state.start_id >= 0 else 0
|
||||
end_id = (
|
||||
self.state.end_id
|
||||
if self.state.end_id >= 0
|
||||
else event_stream.get_latest_event_id()
|
||||
)
|
||||
|
||||
# sanity check
|
||||
if start_id > end_id + 1:
|
||||
logger.warning(
|
||||
f'start_id {start_id} is greater than end_id + 1 ({end_id + 1}). History will be empty.',
|
||||
)
|
||||
self.state.history = []
|
||||
return
|
||||
|
||||
events: list[Event] = []
|
||||
|
||||
# Get rest of history
|
||||
events_to_add = list(
|
||||
event_stream.search_events(
|
||||
start_id=start_id,
|
||||
end_id=end_id,
|
||||
reverse=False,
|
||||
filter=self.agent_history_filter,
|
||||
)
|
||||
)
|
||||
events.extend(events_to_add)
|
||||
|
||||
# Find all delegate action/observation pairs
|
||||
delegate_ranges: list[tuple[int, int]] = []
|
||||
delegate_action_ids: list[int] = [] # stack of unmatched delegate action IDs
|
||||
|
||||
for event in events:
|
||||
if isinstance(event, AgentDelegateAction):
|
||||
delegate_action_ids.append(event.id)
|
||||
# Note: we can get agent=event.agent and task=event.inputs.get('task','')
|
||||
# if we need to track these in the future
|
||||
|
||||
elif isinstance(event, AgentDelegateObservation):
|
||||
# Match with most recent unmatched delegate action
|
||||
if not delegate_action_ids:
|
||||
logger.warning(
|
||||
f'Found AgentDelegateObservation without matching action at id={event.id}',
|
||||
)
|
||||
continue
|
||||
|
||||
action_id = delegate_action_ids.pop()
|
||||
delegate_ranges.append((action_id, event.id))
|
||||
|
||||
# Filter out events between delegate action/observation pairs
|
||||
if delegate_ranges:
|
||||
filtered_events: list[Event] = []
|
||||
current_idx = 0
|
||||
|
||||
for start_id, end_id in sorted(delegate_ranges):
|
||||
# Add events before delegate range
|
||||
filtered_events.extend(
|
||||
event for event in events[current_idx:] if event.id < start_id
|
||||
)
|
||||
|
||||
# Add delegate action and observation
|
||||
filtered_events.extend(
|
||||
event for event in events if event.id in (start_id, end_id)
|
||||
)
|
||||
|
||||
# Update index to after delegate range
|
||||
current_idx = next(
|
||||
(i for i, e in enumerate(events) if e.id > end_id), len(events)
|
||||
)
|
||||
|
||||
# Add any remaining events after last delegate range
|
||||
filtered_events.extend(events[current_idx:])
|
||||
|
||||
self.state.history = filtered_events
|
||||
else:
|
||||
self.state.history = events
|
||||
|
||||
# make sure history is in sync
|
||||
self.state.start_id = start_id
|
||||
|
||||
def close(self, event_stream: EventStream):
|
||||
# we made history, now is the time to rewrite it!
|
||||
# the final state.history will be used by external scripts like evals, tests, etc.
|
||||
# history will need to be complete WITH delegates events
|
||||
# like the regular agent history, it does not include:
|
||||
# - 'hidden' events, events with hidden=True
|
||||
# - backend events (the default 'filtered out' types, types in self.filter_out)
|
||||
start_id = self.state.start_id if self.state.start_id >= 0 else 0
|
||||
end_id = (
|
||||
self.state.end_id
|
||||
if self.state.end_id >= 0
|
||||
else event_stream.get_latest_event_id()
|
||||
)
|
||||
|
||||
self.state.history = list(
|
||||
event_stream.search_events(
|
||||
start_id=start_id,
|
||||
end_id=end_id,
|
||||
reverse=False,
|
||||
filter=self.agent_history_filter,
|
||||
)
|
||||
)
|
||||
|
||||
def add_history(self, event: Event):
|
||||
# if the event is not filtered out, add it to the history
|
||||
if self.agent_history_filter.include(event):
|
||||
self.state.history.append(event)
|
||||
|
||||
def get_trajectory(self, include_screenshots: bool = False) -> list[dict]:
|
||||
return [
|
||||
event_to_trajectory(event, include_screenshots)
|
||||
for event in self.state.history
|
||||
]
|
||||
|
||||
def maybe_increase_control_flags_limits(self, headless_mode: bool):
|
||||
# Iteration and budget extensions are independent of each other
|
||||
# An error will be thrown if any one of the control flags have reached or exceeded its limit
|
||||
self.state.iteration_flag.increase_limit(headless_mode)
|
||||
if self.state.budget_flag:
|
||||
self.state.budget_flag.increase_limit(headless_mode)
|
||||
|
||||
def get_metrics_snapshot(self):
|
||||
"""
|
||||
Deep copy of metrics
|
||||
This serves as a snapshot for the parent's metrics at the time a delegate is created
|
||||
It will be stored and used to compute local metrics for the delegate
|
||||
(since delegates now accumulate metrics from where its parent left off)
|
||||
"""
|
||||
|
||||
return self.state.metrics.copy()
|
||||
|
||||
def save_state(self):
|
||||
"""
|
||||
Save's current state to persistent store
|
||||
"""
|
||||
if self.sid and self.file_store:
|
||||
self.state.save_to_session(self.sid, self.file_store, self.user_id)
|
||||
|
||||
def run_control_flags(self):
|
||||
"""
|
||||
Performs one step of the control flags
|
||||
"""
|
||||
self.state.iteration_flag.step()
|
||||
if self.state.budget_flag:
|
||||
self.state.budget_flag.step()
|
||||
|
||||
def sync_budget_flag_with_metrics(self):
|
||||
"""
|
||||
Ensures that budget flag is up to date with accumulated costs from llm completions
|
||||
Budget flag will monitor for when budget is exceeded
|
||||
"""
|
||||
if self.state.budget_flag:
|
||||
self.state.budget_flag.current_value = self.state.metrics.accumulated_cost
|
||||
|
||||
def merge_metrics(self, metrics: Metrics):
|
||||
"""
|
||||
Merges metrics with the state metrics
|
||||
|
||||
NOTE: this should be refactored in the future. We should have services (draft llm, title autocomplete, condenser, etc)
|
||||
use their own LLMs, but the metrics object should be shared. This way we have one source of truth for accumulated costs from
|
||||
all services
|
||||
|
||||
This would prevent having fragmented stores for metrics, and we don't have the burden of deciding where and how to store them
|
||||
if we decide introduce more specialized services that require llm completions
|
||||
|
||||
"""
|
||||
self.state.metrics.merge(metrics)
|
||||
if self.state.budget_flag:
|
||||
self.state.budget_flag.current_value = self.state.metrics.accumulated_cost
|
||||
@@ -744,27 +744,6 @@ def get_parser() -> argparse.ArgumentParser:
|
||||
type=bool,
|
||||
default=False,
|
||||
)
|
||||
|
||||
# LLM configuration arguments for local models
|
||||
parser.add_argument(
|
||||
'--llm-model',
|
||||
help='LLM model to use (e.g., "lm_studio/devstral", "openai/gpt-4")',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--llm-base-url',
|
||||
help='Base URL for LLM API (required for local models, e.g., "http://localhost:1234/v1")',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--llm-api-key',
|
||||
help='API key for LLM (use "dummy" for local models)',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -842,21 +821,6 @@ def setup_config_from_args(args: argparse.Namespace) -> OpenHandsConfig:
|
||||
raise ValueError(f'Invalid toml file, cannot read {args.llm_config}')
|
||||
config.set_llm_config(llm_config)
|
||||
|
||||
# Override LLM settings with direct CLI arguments
|
||||
if args.llm_model or args.llm_base_url or args.llm_api_key:
|
||||
from pydantic import SecretStr
|
||||
|
||||
llm_config = config.get_llm_config()
|
||||
|
||||
if args.llm_model:
|
||||
llm_config.model = args.llm_model
|
||||
if args.llm_base_url:
|
||||
llm_config.base_url = args.llm_base_url
|
||||
if args.llm_api_key:
|
||||
llm_config.api_key = SecretStr(args.llm_api_key)
|
||||
|
||||
config.set_llm_config(llm_config)
|
||||
|
||||
# Override default agent if provided
|
||||
if args.agent_cls:
|
||||
config.default_agent = args.agent_cls
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from typing import Callable, Protocol
|
||||
|
||||
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
|
||||
import openhands.cli.suppress_warnings # noqa: F401
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.replay import ReplayManager
|
||||
from openhands.controller.state.state import State
|
||||
@@ -139,9 +140,9 @@ async def run_controller(
|
||||
config.mcp_host, config, None
|
||||
)
|
||||
)
|
||||
config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
runtime.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
|
||||
await add_mcp_tools_to_agent(agent, runtime, memory, config)
|
||||
await add_mcp_tools_to_agent(agent, runtime, memory)
|
||||
|
||||
replay_events: list[Event] | None = None
|
||||
if config.replay_trajectory_path:
|
||||
|
||||
@@ -107,6 +107,10 @@ def initialize_repository_for_runtime(
|
||||
gitlab_token = SecretStr(os.environ['GITLAB_TOKEN'])
|
||||
provider_tokens[ProviderType.GITLAB] = ProviderToken(token=gitlab_token)
|
||||
|
||||
if 'BITBUCKET_TOKEN' in os.environ:
|
||||
bitbucket_token = SecretStr(os.environ['BITBUCKET_TOKEN'])
|
||||
provider_tokens[ProviderType.BITBUCKET] = ProviderToken(token=bitbucket_token)
|
||||
|
||||
secret_store = (
|
||||
UserSecrets(provider_tokens=provider_tokens) if provider_tokens else None
|
||||
)
|
||||
@@ -206,8 +210,8 @@ def create_controller(
|
||||
|
||||
controller = AgentController(
|
||||
agent=agent,
|
||||
max_iterations=config.max_iterations,
|
||||
max_budget_per_task=config.max_budget_per_task,
|
||||
iteration_delta=config.max_iterations,
|
||||
budget_per_task_delta=config.max_budget_per_task,
|
||||
agent_to_llm_config=config.get_agent_to_llm_config_map(),
|
||||
event_stream=event_stream,
|
||||
initial_state=initial_state,
|
||||
|
||||
@@ -9,6 +9,7 @@ from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
@dataclass
|
||||
class MessageAction(Action):
|
||||
content: str
|
||||
file_urls: list[str] | None = None
|
||||
image_urls: list[str] | None = None
|
||||
wait_for_response: bool = False
|
||||
action: str = ActionType.MESSAGE
|
||||
@@ -33,6 +34,9 @@ class MessageAction(Action):
|
||||
if self.image_urls:
|
||||
for url in self.image_urls:
|
||||
ret += f'\nIMAGE_URL: {url}'
|
||||
if self.file_urls:
|
||||
for url in self.file_urls:
|
||||
ret += f'\nFILE_URL: {url}'
|
||||
return ret
|
||||
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ class AsyncEventStoreWrapper:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Create an async generator that yields events
|
||||
for event in self.event_store.get_events(*self.args, **self.kwargs):
|
||||
# Run the blocking get_events() in a thread pool
|
||||
for event in self.event_store.search_events(*self.args, **self.kwargs):
|
||||
# Run the blocking search_events() in a thread pool
|
||||
def get_event(e: Event = event) -> Event:
|
||||
return e
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ class EventStore(EventStoreABC):
|
||||
return self.cur_id - 1
|
||||
|
||||
def filtered_events_by_source(self, source: EventSource) -> Iterable[Event]:
|
||||
for event in self.get_events():
|
||||
for event in self.search_events():
|
||||
if event.source == source:
|
||||
yield event
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user