mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
33 Commits
add-cli-ll
...
enyst/cli-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a692cb7b9 | ||
|
|
5156fe958b | ||
|
|
cc86e32598 | ||
|
|
b7a6190133 | ||
|
|
54af9ff3fe | ||
|
|
ef582a6335 | ||
|
|
d5f5e34ead | ||
|
|
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
|
||||
|
||||
|
||||
36
Makefile
36
Makefile
@@ -12,6 +12,7 @@ DEFAULT_MODEL = "gpt-4o"
|
||||
CONFIG_FILE = config.toml
|
||||
PRE_COMMIT_CONFIG_PATH = "./dev_config/python/.pre-commit-config.yaml"
|
||||
PYTHON_VERSION = 3.12
|
||||
KIND_CLUSTER_NAME = "local-hands"
|
||||
|
||||
# ANSI color codes
|
||||
GREEN=$(shell tput -Txterm setaf 2)
|
||||
@@ -199,6 +200,40 @@ lint:
|
||||
@$(MAKE) -s lint-frontend
|
||||
@$(MAKE) -s lint-backend
|
||||
|
||||
kind:
|
||||
@echo "$(YELLOW)Checking if kind is installed...$(RESET)"
|
||||
@if ! command -v kind > /dev/null; then \
|
||||
echo "$(RED)kind is not installed. Please install kind with `brew install kind` to continue$(RESET)"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "$(BLUE)kind $(shell kind version) is already installed.$(RESET)"; \
|
||||
fi
|
||||
@echo "$(YELLOW)Checking if kind cluster '$(KIND_CLUSTER_NAME)' already exists...$(RESET)"
|
||||
@if kind get clusters | grep -q "^$(KIND_CLUSTER_NAME)$$"; then \
|
||||
echo "$(BLUE)Kind cluster '$(KIND_CLUSTER_NAME)' already exists.$(RESET)"; \
|
||||
kubectl config use-context kind-$(KIND_CLUSTER_NAME); \
|
||||
else \
|
||||
echo "$(YELLOW)Creating kind cluster '$(KIND_CLUSTER_NAME)'...$(RESET)"; \
|
||||
kind create cluster --name $(KIND_CLUSTER_NAME) --config kind/cluster.yaml; \
|
||||
fi
|
||||
@echo "$(YELLOW)Checking if mirrord is installed...$(RESET)"
|
||||
@if ! command -v mirrord > /dev/null; then \
|
||||
echo "$(RED)mirrord is not installed. Please install mirrord with `brew install metalbear-co/mirrord/mirrord` to continue$(RESET)"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "$(BLUE)mirrord $(shell mirrord --version) is already installed.$(RESET)"; \
|
||||
fi
|
||||
@echo "$(YELLOW)Installing k8s mirrord resources...$(RESET)"
|
||||
@kubectl apply -f kind/manifests
|
||||
@echo "$(GREEN)Mirrord resources installed successfully.$(RESET)"
|
||||
@echo "$(YELLOW)Waiting for Mirrord pod to be ready.$(RESET)"
|
||||
@sleep 5
|
||||
@kubectl wait --for=condition=Available deployment/ubuntu-dev
|
||||
@echo "$(YELLOW)Waiting for Nginx to be ready.$(RESET)"
|
||||
@kubectl -n ingress-nginx wait --for=condition=Available deployment/ingress-nginx-controller
|
||||
@echo "$(YELLOW)Running make run inside of mirrord.$(RESET)"
|
||||
@mirrord exec --target deployment/ubuntu-dev -- make run
|
||||
|
||||
test-frontend:
|
||||
@echo "$(YELLOW)Running tests for frontend...$(RESET)"
|
||||
@cd frontend && npm run test
|
||||
@@ -333,3 +368,4 @@ help:
|
||||
|
||||
# Phony targets
|
||||
.PHONY: build check-dependencies check-system check-python check-npm check-nodejs check-docker check-poetry install-python-dependencies install-frontend-dependencies install-pre-commit-hooks lint-backend lint-frontend lint test-frontend test build-frontend start-backend start-frontend _run_setup run run-wsl setup-config setup-config-prompts setup-config-basic openhands-cloud-run docker-dev docker-run clean help
|
||||
.PHONY: kind
|
||||
|
||||
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密钥。
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
#max_budget_per_task = 0.0
|
||||
|
||||
# Maximum number of iterations
|
||||
#max_iterations = 250
|
||||
#max_iterations = 500
|
||||
|
||||
# Path to mount the workspace in the sandbox
|
||||
#workspace_mount_path_in_sandbox = "/workspace"
|
||||
@@ -415,3 +415,47 @@ type = "noop"
|
||||
# Configuration for the evaluation, please refer to the specific evaluation
|
||||
# plugin for the available options
|
||||
##############################################################################
|
||||
|
||||
|
||||
########################### Kubernetes #######################################
|
||||
# Kubernetes configuration when using the Kubernetes runtime
|
||||
##############################################################################
|
||||
[kubernetes]
|
||||
# The Kubernetes namespace to use for OpenHands resources
|
||||
#namespace = "default"
|
||||
|
||||
# Domain for ingress resources
|
||||
#ingress_domain = "localhost"
|
||||
|
||||
# Size of the persistent volume claim
|
||||
#pvc_storage_size = "2Gi"
|
||||
|
||||
# Storage class for persistent volume claims
|
||||
#pvc_storage_class = "standard"
|
||||
|
||||
# CPU request for runtime pods
|
||||
#resource_cpu_request = "1"
|
||||
|
||||
# Memory request for runtime pods
|
||||
#resource_memory_request = "1Gi"
|
||||
|
||||
# Memory limit for runtime pods
|
||||
#resource_memory_limit = "2Gi"
|
||||
|
||||
# Optional name of image pull secret for private registries
|
||||
#image_pull_secret = ""
|
||||
|
||||
# Optional name of TLS secret for ingress
|
||||
#ingress_tls_secret = ""
|
||||
|
||||
# Optional node selector key for pod scheduling
|
||||
#node_selector_key = ""
|
||||
|
||||
# Optional node selector value for pod scheduling
|
||||
#node_selector_val = ""
|
||||
|
||||
# Optional YAML string defining pod tolerations
|
||||
#tolerations_yaml = ""
|
||||
|
||||
# Run the runtime sandbox container in privileged mode for use with docker-in-docker
|
||||
#privileged = false
|
||||
|
||||
@@ -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,6 +7,7 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
exclude: docs/modules/python
|
||||
- id: check-yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("ConversationPanel", () => {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
},
|
||||
@@ -273,6 +274,7 @@ describe("ConversationPanel", () => {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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]"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BudgetProgressBar } from "./budget-progress-bar";
|
||||
import { BudgetUsageText } from "./budget-usage-text";
|
||||
|
||||
interface BudgetDisplayProps {
|
||||
cost: number | null;
|
||||
maxBudgetPerTask: number | null;
|
||||
}
|
||||
|
||||
export function BudgetDisplay({ cost, maxBudgetPerTask }: BudgetDisplayProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Don't render anything if cost is not available
|
||||
if (cost === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-neutral-700">
|
||||
{maxBudgetPerTask !== null && maxBudgetPerTask > 0 ? (
|
||||
<>
|
||||
<BudgetProgressBar currentCost={cost} maxBudget={maxBudgetPerTask} />
|
||||
<BudgetUsageText currentCost={cost} maxBudget={maxBudgetPerTask} />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$NO_BUDGET_LIMIT)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
interface BudgetProgressBarProps {
|
||||
currentCost: number;
|
||||
maxBudget: number;
|
||||
}
|
||||
|
||||
export function BudgetProgressBar({
|
||||
currentCost,
|
||||
maxBudget,
|
||||
}: BudgetProgressBarProps) {
|
||||
const usagePercentage = (currentCost / maxBudget) * 100;
|
||||
const isNearLimit = usagePercentage > 80;
|
||||
|
||||
return (
|
||||
<div className="w-full h-1.5 bg-neutral-700 rounded-full overflow-hidden mt-1">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
isNearLimit ? "bg-red-500" : "bg-blue-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(100, usagePercentage)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface BudgetUsageTextProps {
|
||||
currentCost: number;
|
||||
maxBudget: number;
|
||||
}
|
||||
|
||||
export function BudgetUsageText({
|
||||
currentCost,
|
||||
maxBudget,
|
||||
}: BudgetUsageTextProps) {
|
||||
const { t } = useTranslation();
|
||||
const usagePercentage = (currentCost / maxBudget) * 100;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-neutral-400">
|
||||
${currentCost.toFixed(4)} / ${maxBudget.toFixed(4)} (
|
||||
{usagePercentage.toFixed(2)}% {t(I18nKey.CONVERSATION$USED)})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { EllipsisButton } from "./ellipsis-button";
|
||||
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
import { SystemMessageModal } from "./system-message-modal";
|
||||
import { MicroagentsModal } from "./microagents-modal";
|
||||
import { BudgetDisplay } from "./budget-display";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BaseModal } from "../../shared/modals/base-modal/base-modal";
|
||||
import { RootState } from "#/store";
|
||||
@@ -285,7 +286,7 @@ export function ConversationCard({
|
||||
<div className="rounded-md p-3">
|
||||
<div className="grid gap-3">
|
||||
{metrics?.cost !== null && (
|
||||
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span className="text-lg font-semibold">
|
||||
{t(I18nKey.CONVERSATION$TOTAL_COST)}
|
||||
</span>
|
||||
@@ -294,6 +295,10 @@ export function ConversationCard({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<BudgetDisplay
|
||||
cost={metrics?.cost ?? null}
|
||||
maxBudgetPerTask={metrics?.max_budget_per_task ?? null}
|
||||
/>
|
||||
|
||||
{metrics?.usage !== null && (
|
||||
<>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
enable_proactive_conversation_starters:
|
||||
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
|
||||
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
|
||||
};
|
||||
|
||||
await OpenHands.saveSettings(apiSettings);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -27,6 +27,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
apiSettings.enable_proactive_conversation_starters,
|
||||
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
|
||||
SEARCH_API_KEY: apiSettings.search_api_key || "",
|
||||
MAX_BUDGET_PER_TASK: apiSettings.max_budget_per_task,
|
||||
EMAIL: apiSettings.email || "",
|
||||
EMAIL_VERIFIED: apiSettings.email_verified,
|
||||
MCP_CONFIG: apiSettings.mcp_config,
|
||||
|
||||
@@ -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",
|
||||
@@ -121,6 +121,8 @@ export enum I18nKey {
|
||||
SETTINGS$LLM_SETTINGS = "SETTINGS$LLM_SETTINGS",
|
||||
SETTINGS$GIT_SETTINGS = "SETTINGS$GIT_SETTINGS",
|
||||
SETTINGS$SOUND_NOTIFICATIONS = "SETTINGS$SOUND_NOTIFICATIONS",
|
||||
SETTINGS$MAX_BUDGET_PER_TASK = "SETTINGS$MAX_BUDGET_PER_TASK",
|
||||
SETTINGS$MAX_BUDGET_PER_CONVERSATION = "SETTINGS$MAX_BUDGET_PER_CONVERSATION",
|
||||
SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS",
|
||||
SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY",
|
||||
SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL",
|
||||
@@ -170,10 +172,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 +250,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",
|
||||
@@ -493,6 +496,9 @@ export enum I18nKey {
|
||||
CONVERSATION$DOWNLOAD_ERROR = "CONVERSATION$DOWNLOAD_ERROR",
|
||||
CONVERSATION$UPDATED = "CONVERSATION$UPDATED",
|
||||
CONVERSATION$TOTAL_COST = "CONVERSATION$TOTAL_COST",
|
||||
CONVERSATION$BUDGET = "CONVERSATION$BUDGET",
|
||||
CONVERSATION$BUDGET_USAGE = "CONVERSATION$BUDGET_USAGE",
|
||||
CONVERSATION$NO_BUDGET_LIMIT = "CONVERSATION$NO_BUDGET_LIMIT",
|
||||
CONVERSATION$INPUT = "CONVERSATION$INPUT",
|
||||
CONVERSATION$OUTPUT = "CONVERSATION$OUTPUT",
|
||||
CONVERSATION$TOTAL = "CONVERSATION$TOTAL",
|
||||
@@ -508,6 +514,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 +531,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 +596,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",
|
||||
@@ -1935,6 +1935,38 @@
|
||||
"tr": "Ses Bildirimleri",
|
||||
"uk": "Звукові сповіщення"
|
||||
},
|
||||
"SETTINGS$MAX_BUDGET_PER_TASK": {
|
||||
"en": "Maximum Budget Per Task",
|
||||
"ja": "タスクごとの最大予算",
|
||||
"zh-CN": "每个任务的最大预算",
|
||||
"zh-TW": "每個任務的最大預算",
|
||||
"ko-KR": "작업당 최대 예산",
|
||||
"de": "Maximales Budget pro Aufgabe",
|
||||
"no": "Maksimalt budsjett per oppgave",
|
||||
"it": "Budget massimo per attività",
|
||||
"pt": "Orçamento máximo por tarefa",
|
||||
"es": "Presupuesto máximo por tarea",
|
||||
"ar": "الميزانية القصوى لكل مهمة",
|
||||
"fr": "Budget maximum par tâche",
|
||||
"tr": "Görev Başına Maksimum Bütçe",
|
||||
"uk": "Максимальний бюджет на завдання"
|
||||
},
|
||||
"SETTINGS$MAX_BUDGET_PER_CONVERSATION": {
|
||||
"en": "Maximum Budget Per Conversation",
|
||||
"ja": "会話ごとの最大予算",
|
||||
"zh-CN": "每次对话的最大预算",
|
||||
"zh-TW": "每次對話的最大預算",
|
||||
"ko-KR": "대화당 최대 예산",
|
||||
"de": "Maximales Budget pro Konversation",
|
||||
"no": "Maksimalt budsjett per samtale",
|
||||
"it": "Budget massimo per conversazione",
|
||||
"pt": "Orçamento máximo por conversa",
|
||||
"es": "Presupuesto máximo por conversación",
|
||||
"ar": "الميزانية القصوى لكل محادثة",
|
||||
"fr": "Budget maximum par conversation",
|
||||
"tr": "Konuşma Başına Maksimum Bütçe",
|
||||
"uk": "Максимальний бюджет на розмову"
|
||||
},
|
||||
"SETTINGS$PROACTIVE_CONVERSATION_STARTERS": {
|
||||
"en": "Suggest Tasks on GitHub",
|
||||
"ja": "GitHubでタスクを提案",
|
||||
@@ -2719,22 +2751,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 +2799,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 +3999,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": "切断されました",
|
||||
@@ -7887,6 +7935,54 @@
|
||||
"tr": "Toplam Maliyet",
|
||||
"uk": "Загальна вартість"
|
||||
},
|
||||
"CONVERSATION$BUDGET": {
|
||||
"en": "Budget",
|
||||
"ja": "予算",
|
||||
"zh-CN": "预算",
|
||||
"zh-TW": "預算",
|
||||
"ko-KR": "예산",
|
||||
"de": "Budget",
|
||||
"no": "Budsjett",
|
||||
"it": "Budget",
|
||||
"pt": "Orçamento",
|
||||
"es": "Presupuesto",
|
||||
"ar": "الميزانية",
|
||||
"fr": "Budget",
|
||||
"tr": "Bütçe",
|
||||
"uk": "Бюджет"
|
||||
},
|
||||
"CONVERSATION$BUDGET_USAGE": {
|
||||
"en": "% used",
|
||||
"ja": "% 使用済み",
|
||||
"zh-CN": "% 已使用",
|
||||
"zh-TW": "% 已使用",
|
||||
"ko-KR": "% 사용됨",
|
||||
"de": "% verwendet",
|
||||
"no": "% brukt",
|
||||
"it": "% utilizzato",
|
||||
"pt": "% utilizado",
|
||||
"es": "% utilizado",
|
||||
"ar": "% مستخدم",
|
||||
"fr": "% utilisé",
|
||||
"tr": "% kullanıldı",
|
||||
"uk": "% використано"
|
||||
},
|
||||
"CONVERSATION$NO_BUDGET_LIMIT": {
|
||||
"en": "No budget limit",
|
||||
"ja": "予算制限なし",
|
||||
"zh-CN": "无预算限制",
|
||||
"zh-TW": "無預算限制",
|
||||
"ko-KR": "예산 제한 없음",
|
||||
"de": "Kein Budgetlimit",
|
||||
"no": "Ingen budsjettgrense",
|
||||
"it": "Nessun limite di budget",
|
||||
"pt": "Sem limite de orçamento",
|
||||
"es": "Sin límite de presupuesto",
|
||||
"ar": "لا حد للميزانية",
|
||||
"fr": "Pas de limite de budget",
|
||||
"tr": "Bütçe limiti yok",
|
||||
"uk": "Без обмеження бюджету"
|
||||
},
|
||||
"CONVERSATION$INPUT": {
|
||||
"en": "- Input:",
|
||||
"ja": "- 入力:",
|
||||
@@ -8127,6 +8223,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 +8495,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 +9534,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": "Не вдалося надіслати відгук"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
enable_proactive_conversation_starters:
|
||||
DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
|
||||
max_budget_per_task: DEFAULT_SETTINGS.MAX_BUDGET_PER_TASK,
|
||||
};
|
||||
|
||||
const MOCK_USER_PREFERENCES: {
|
||||
@@ -140,13 +141,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 +158,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"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AvailableLanguages } from "#/i18n";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { parseMaxBudgetPerTask } from "#/utils/settings-utils";
|
||||
|
||||
function AppSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -36,6 +38,8 @@ function AppSettingsScreen() {
|
||||
proactiveConversationsSwitchHasChanged,
|
||||
setProactiveConversationsSwitchHasChanged,
|
||||
] = React.useState(false);
|
||||
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
|
||||
React.useState(false);
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const languageLabel = formData.get("language-input")?.toString();
|
||||
@@ -53,12 +57,18 @@ function AppSettingsScreen() {
|
||||
formData.get("enable-proactive-conversations-switch")?.toString() ===
|
||||
"on";
|
||||
|
||||
const maxBudgetPerTaskValue = formData
|
||||
.get("max-budget-per-task-input")
|
||||
?.toString();
|
||||
const maxBudgetPerTask = parseMaxBudgetPerTask(maxBudgetPerTaskValue || "");
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
LANGUAGE: language,
|
||||
user_consents_to_analytics: enableAnalytics,
|
||||
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
|
||||
MAX_BUDGET_PER_TASK: maxBudgetPerTask,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -74,6 +84,7 @@ function AppSettingsScreen() {
|
||||
setAnalyticsSwitchHasChanged(false);
|
||||
setSoundNotificationsSwitchHasChanged(false);
|
||||
setProactiveConversationsSwitchHasChanged(false);
|
||||
setMaxBudgetPerTaskHasChanged(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -110,11 +121,18 @@ function AppSettingsScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
|
||||
const newValue = parseMaxBudgetPerTask(value);
|
||||
const currentValue = settings?.MAX_BUDGET_PER_TASK;
|
||||
setMaxBudgetPerTaskHasChanged(newValue !== currentValue);
|
||||
};
|
||||
|
||||
const formIsClean =
|
||||
!languageInputHasChanged &&
|
||||
!analyticsSwitchHasChanged &&
|
||||
!soundNotificationsSwitchHasChanged &&
|
||||
!proactiveConversationsSwitchHasChanged;
|
||||
!proactiveConversationsSwitchHasChanged &&
|
||||
!maxBudgetPerTaskHasChanged;
|
||||
|
||||
const shouldBeLoading = !settings || isLoading || isPending;
|
||||
|
||||
@@ -139,7 +157,7 @@ function AppSettingsScreen() {
|
||||
defaultIsToggled={!!settings.USER_CONSENTS_TO_ANALYTICS}
|
||||
onToggle={checkIfAnalyticsSwitchHasChanged}
|
||||
>
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)}
|
||||
</SettingsSwitch>
|
||||
|
||||
<SettingsSwitch
|
||||
@@ -163,6 +181,19 @@ function AppSettingsScreen() {
|
||||
{t(I18nKey.SETTINGS$PROACTIVE_CONVERSATION_STARTERS)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="max-budget-per-task-input"
|
||||
name="max-budget-per-task-input"
|
||||
type="number"
|
||||
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
|
||||
defaultValue={settings.MAX_BUDGET_PER_TASK?.toString() || ""}
|
||||
onChange={checkIfMaxBudgetPerTaskHasChanged}
|
||||
placeholder="Maximum budget per conversation in USD"
|
||||
min={1}
|
||||
step={1}
|
||||
className="w-[680px]" // Match the width of the language field
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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") },
|
||||
];
|
||||
|
||||
@@ -22,6 +22,7 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
if (message.llm_metrics) {
|
||||
const metrics = {
|
||||
cost: message.llm_metrics?.accumulated_cost ?? null,
|
||||
max_budget_per_task: message.llm_metrics?.max_budget_per_task ?? null,
|
||||
usage: message.llm_metrics?.accumulated_token_usage ?? null,
|
||||
};
|
||||
store.dispatch(setMetrics(metrics));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
|
||||
SEARCH_API_KEY: "",
|
||||
IS_NEW_USER: true,
|
||||
MAX_BUDGET_PER_TASK: null,
|
||||
EMAIL: "",
|
||||
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
|
||||
MCP_CONFIG: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface MetricsState {
|
||||
cost: number | null;
|
||||
max_budget_per_task: number | null;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
@@ -14,6 +15,7 @@ interface MetricsState {
|
||||
|
||||
const initialState: MetricsState = {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
};
|
||||
|
||||
@@ -23,6 +25,7 @@ const metricsSlice = createSlice({
|
||||
reducers: {
|
||||
setMetrics: (state, action: PayloadAction<MetricsState>) => {
|
||||
state.cost = action.payload.cost;
|
||||
state.max_budget_per_task = action.payload.max_budget_per_task;
|
||||
state.usage = action.payload.usage;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ActionMessage {
|
||||
// LLM metrics information
|
||||
llm_metrics?: {
|
||||
accumulated_cost: number;
|
||||
max_budget_per_task: number | null;
|
||||
accumulated_token_usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const ProviderOptions = {
|
||||
github: "github",
|
||||
gitlab: "gitlab",
|
||||
bitbucket: "bitbucket",
|
||||
} as const;
|
||||
|
||||
export type Provider = keyof typeof ProviderOptions;
|
||||
@@ -45,6 +46,7 @@ export type Settings = {
|
||||
SEARCH_API_KEY?: string;
|
||||
IS_NEW_USER?: boolean;
|
||||
MCP_CONFIG?: MCPConfig;
|
||||
MAX_BUDGET_PER_TASK: number | null;
|
||||
EMAIL?: string;
|
||||
EMAIL_VERIFIED?: boolean;
|
||||
};
|
||||
@@ -66,6 +68,7 @@ export type ApiSettings = {
|
||||
user_consents_to_analytics: boolean | null;
|
||||
search_api_key?: string;
|
||||
provider_tokens_set: Partial<Record<Provider, string | null>>;
|
||||
max_budget_per_task: number | null;
|
||||
mcp_config?: {
|
||||
sse_servers: (string | MCPSSEServer)[];
|
||||
stdio_servers: MCPStdioServer[];
|
||||
|
||||
49
frontend/src/utils/__tests__/settings-utils.test.ts
Normal file
49
frontend/src/utils/__tests__/settings-utils.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMaxBudgetPerTask } from "../settings-utils";
|
||||
|
||||
describe("parseMaxBudgetPerTask", () => {
|
||||
it("should return null for empty string", () => {
|
||||
expect(parseMaxBudgetPerTask("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for whitespace-only string", () => {
|
||||
expect(parseMaxBudgetPerTask(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-numeric string", () => {
|
||||
expect(parseMaxBudgetPerTask("abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for values less than 1", () => {
|
||||
expect(parseMaxBudgetPerTask("0")).toBeNull();
|
||||
expect(parseMaxBudgetPerTask("0.5")).toBeNull();
|
||||
expect(parseMaxBudgetPerTask("-1")).toBeNull();
|
||||
expect(parseMaxBudgetPerTask("-10.5")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the parsed value for valid numbers >= 1", () => {
|
||||
expect(parseMaxBudgetPerTask("1")).toBe(1);
|
||||
expect(parseMaxBudgetPerTask("1.0")).toBe(1);
|
||||
expect(parseMaxBudgetPerTask("1.5")).toBe(1.5);
|
||||
expect(parseMaxBudgetPerTask("10")).toBe(10);
|
||||
expect(parseMaxBudgetPerTask("100.99")).toBe(100.99);
|
||||
});
|
||||
|
||||
it("should handle string numbers with leading/trailing whitespace", () => {
|
||||
expect(parseMaxBudgetPerTask(" 1 ")).toBe(1);
|
||||
expect(parseMaxBudgetPerTask(" 10.5 ")).toBe(10.5);
|
||||
});
|
||||
|
||||
it("should return null for edge cases", () => {
|
||||
expect(parseMaxBudgetPerTask("0.999")).toBeNull();
|
||||
expect(parseMaxBudgetPerTask("NaN")).toBeNull();
|
||||
expect(parseMaxBudgetPerTask("Infinity")).toBeNull();
|
||||
expect(parseMaxBudgetPerTask("-Infinity")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle scientific notation", () => {
|
||||
expect(parseMaxBudgetPerTask("1e0")).toBe(1);
|
||||
expect(parseMaxBudgetPerTask("1.5e1")).toBe(15);
|
||||
expect(parseMaxBudgetPerTask("5e-1")).toBeNull(); // 0.5, which is < 1
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -47,6 +47,24 @@ const extractAdvancedFormData = (formData: FormData) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses and validates a max budget per task value.
|
||||
* Ensures the value is at least 1 dollar.
|
||||
* @param value - The string value to parse
|
||||
* @returns The parsed number if valid (>= 1), null otherwise
|
||||
*/
|
||||
export const parseMaxBudgetPerTask = (value: string): number | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = parseFloat(value);
|
||||
// Ensure the value is at least 1 dollar and is a finite number
|
||||
return parsedValue && parsedValue >= 1 && Number.isFinite(parsedValue)
|
||||
? parsedValue
|
||||
: null;
|
||||
};
|
||||
|
||||
export const extractSettings = (
|
||||
formData: FormData,
|
||||
): Partial<Settings> & { llm_api_key?: string | null } => {
|
||||
|
||||
9
kind/cluster.yaml
Normal file
9
kind/cluster.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
name: local-hands
|
||||
nodes:
|
||||
- role: control-plane
|
||||
extraPortMappings:
|
||||
- containerPort: 80 # node port on the cluster for nginx.
|
||||
hostPort: 80 # local port for nginx http.
|
||||
19
kind/manifests/deployment.yaml
Normal file
19
kind/manifests/deployment.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ubuntu-dev
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ubuntu-dev
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ubuntu-dev
|
||||
spec:
|
||||
containers:
|
||||
- name: ubuntu
|
||||
image: ubuntu:22.04
|
||||
command: ["sleep", "infinity"]
|
||||
678
kind/manifests/nginx.yaml
Normal file
678
kind/manifests/nginx.yaml
Normal file
@@ -0,0 +1,678 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
name: ingress-nginx
|
||||
---
|
||||
apiVersion: v1
|
||||
automountServiceAccountToken: true
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx
|
||||
namespace: ingress-nginx
|
||||
---
|
||||
apiVersion: v1
|
||||
automountServiceAccountToken: true
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission
|
||||
namespace: ingress-nginx
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx
|
||||
namespace: ingress-nginx
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- namespaces
|
||||
verbs:
|
||||
- get
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- configmaps
|
||||
- pods
|
||||
- secrets
|
||||
- endpoints
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- services
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- networking.k8s.io
|
||||
resources:
|
||||
- ingresses
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- networking.k8s.io
|
||||
resources:
|
||||
- ingresses/status
|
||||
verbs:
|
||||
- update
|
||||
- apiGroups:
|
||||
- networking.k8s.io
|
||||
resources:
|
||||
- ingressclasses
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- coordination.k8s.io
|
||||
resourceNames:
|
||||
- ingress-nginx-leader
|
||||
resources:
|
||||
- leases
|
||||
verbs:
|
||||
- get
|
||||
- update
|
||||
- apiGroups:
|
||||
- coordination.k8s.io
|
||||
resources:
|
||||
- leases
|
||||
verbs:
|
||||
- create
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- events
|
||||
verbs:
|
||||
- create
|
||||
- patch
|
||||
- apiGroups:
|
||||
- discovery.k8s.io
|
||||
resources:
|
||||
- endpointslices
|
||||
verbs:
|
||||
- list
|
||||
- watch
|
||||
- get
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission
|
||||
namespace: ingress-nginx
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
verbs:
|
||||
- get
|
||||
- create
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- configmaps
|
||||
- endpoints
|
||||
- nodes
|
||||
- pods
|
||||
- secrets
|
||||
- namespaces
|
||||
verbs:
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- coordination.k8s.io
|
||||
resources:
|
||||
- leases
|
||||
verbs:
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- nodes
|
||||
verbs:
|
||||
- get
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- services
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- networking.k8s.io
|
||||
resources:
|
||||
- ingresses
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- events
|
||||
verbs:
|
||||
- create
|
||||
- patch
|
||||
- apiGroups:
|
||||
- networking.k8s.io
|
||||
resources:
|
||||
- ingresses/status
|
||||
verbs:
|
||||
- update
|
||||
- apiGroups:
|
||||
- networking.k8s.io
|
||||
resources:
|
||||
- ingressclasses
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- discovery.k8s.io
|
||||
resources:
|
||||
- endpointslices
|
||||
verbs:
|
||||
- list
|
||||
- watch
|
||||
- get
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission
|
||||
rules:
|
||||
- apiGroups:
|
||||
- admissionregistration.k8s.io
|
||||
resources:
|
||||
- validatingwebhookconfigurations
|
||||
verbs:
|
||||
- get
|
||||
- update
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx
|
||||
namespace: ingress-nginx
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: ingress-nginx
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: ingress-nginx
|
||||
namespace: ingress-nginx
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission
|
||||
namespace: ingress-nginx
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: ingress-nginx-admission
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: ingress-nginx-admission
|
||||
namespace: ingress-nginx
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: ingress-nginx
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: ingress-nginx
|
||||
namespace: ingress-nginx
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: ingress-nginx-admission
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: ingress-nginx-admission
|
||||
namespace: ingress-nginx
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-controller
|
||||
namespace: ingress-nginx
|
||||
data:
|
||||
worker-processes: "2" # Set to a lower number than default
|
||||
max-worker-connections: "1024"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-controller
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
ipFamilies:
|
||||
- IPv4
|
||||
ipFamilyPolicy: SingleStack
|
||||
ports:
|
||||
- appProtocol: http
|
||||
name: http
|
||||
port: 80
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
- appProtocol: https
|
||||
name: https
|
||||
port: 443
|
||||
protocol: TCP
|
||||
targetPort: https
|
||||
selector:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
type: LoadBalancer
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-controller-admission
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
ports:
|
||||
- appProtocol: https
|
||||
name: https-webhook
|
||||
port: 443
|
||||
targetPort: webhook
|
||||
selector:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-controller
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
minReadySeconds: 0
|
||||
revisionHistoryLimit: 10
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
strategy:
|
||||
rollingUpdate:
|
||||
maxUnavailable: 1
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
spec:
|
||||
containers:
|
||||
- args:
|
||||
- /nginx-ingress-controller
|
||||
- --election-id=ingress-nginx-leader
|
||||
- --controller-class=k8s.io/ingress-nginx
|
||||
- --ingress-class=nginx
|
||||
- --configmap=$(POD_NAMESPACE)/ingress-nginx-controller
|
||||
- --validating-webhook=:8443
|
||||
- --validating-webhook-certificate=/usr/local/certificates/cert
|
||||
- --validating-webhook-key=/usr/local/certificates/key
|
||||
- --watch-ingress-without-class=true
|
||||
- --publish-status-address=localhost
|
||||
env:
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: POD_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: LD_PRELOAD
|
||||
value: /usr/local/lib/libmimalloc.so
|
||||
image: registry.k8s.io/ingress-nginx/controller:v1.12.1@sha256:9724476b928967173d501040631b23ba07f47073999e80e34b120e8db5f234d5
|
||||
imagePullPolicy: IfNotPresent
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command:
|
||||
- /wait-shutdown
|
||||
livenessProbe:
|
||||
failureThreshold: 5
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 10254
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 1
|
||||
name: controller
|
||||
ports:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
name: http
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
name: https
|
||||
protocol: TCP
|
||||
- containerPort: 8443
|
||||
name: webhook
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
failureThreshold: 3
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 10254
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 300m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
add:
|
||||
- NET_BIND_SERVICE
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: false
|
||||
runAsGroup: 82
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumeMounts:
|
||||
- mountPath: /usr/local/certificates/
|
||||
name: webhook-cert
|
||||
readOnly: true
|
||||
dnsPolicy: ClusterFirst
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
serviceAccountName: ingress-nginx
|
||||
terminationGracePeriodSeconds: 0
|
||||
tolerations:
|
||||
- effect: NoSchedule
|
||||
key: node-role.kubernetes.io/master
|
||||
operator: Equal
|
||||
- effect: NoSchedule
|
||||
key: node-role.kubernetes.io/control-plane
|
||||
operator: Equal
|
||||
volumes:
|
||||
- name: webhook-cert
|
||||
secret:
|
||||
secretName: ingress-nginx-admission
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission-create
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission-create
|
||||
spec:
|
||||
containers:
|
||||
- args:
|
||||
- create
|
||||
- --host=ingress-nginx-controller-admission,ingress-nginx-controller-admission.$(POD_NAMESPACE).svc
|
||||
- --namespace=$(POD_NAMESPACE)
|
||||
- --secret-name=ingress-nginx-admission
|
||||
env:
|
||||
- name: POD_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
image: registry.k8s.io/ingress-nginx/kube-webhook-certgen:v1.4.4@sha256:a9f03b34a3cbfbb26d103a14046ab2c5130a80c3d69d526ff8063d2b37b9fd3f
|
||||
imagePullPolicy: IfNotPresent
|
||||
name: create
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsGroup: 65532
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: ingress-nginx-admission
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission-patch
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission-patch
|
||||
spec:
|
||||
containers:
|
||||
- args:
|
||||
- patch
|
||||
- --webhook-name=ingress-nginx-admission
|
||||
- --namespace=$(POD_NAMESPACE)
|
||||
- --patch-mutating=false
|
||||
- --secret-name=ingress-nginx-admission
|
||||
- --patch-failure-policy=Fail
|
||||
env:
|
||||
- name: POD_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
image: registry.k8s.io/ingress-nginx/kube-webhook-certgen:v1.4.4@sha256:a9f03b34a3cbfbb26d103a14046ab2c5130a80c3d69d526ff8063d2b37b9fd3f
|
||||
imagePullPolicy: IfNotPresent
|
||||
name: patch
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsGroup: 65532
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: ingress-nginx-admission
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: IngressClass
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: controller
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: nginx
|
||||
spec:
|
||||
controller: k8s.io/ingress-nginx
|
||||
---
|
||||
apiVersion: admissionregistration.k8s.io/v1
|
||||
kind: ValidatingWebhookConfiguration
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/component: admission-webhook
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/part-of: ingress-nginx
|
||||
app.kubernetes.io/version: 1.12.1
|
||||
name: ingress-nginx-admission
|
||||
webhooks:
|
||||
- admissionReviewVersions:
|
||||
- v1
|
||||
clientConfig:
|
||||
service:
|
||||
name: ingress-nginx-controller-admission
|
||||
namespace: ingress-nginx
|
||||
path: /networking/v1/ingresses
|
||||
port: 443
|
||||
failurePolicy: Fail
|
||||
matchPolicy: Equivalent
|
||||
name: validate.nginx.ingress.kubernetes.io
|
||||
rules:
|
||||
- apiGroups:
|
||||
- networking.k8s.io
|
||||
apiVersions:
|
||||
- v1
|
||||
operations:
|
||||
- CREATE
|
||||
- UPDATE
|
||||
resources:
|
||||
- ingresses
|
||||
sideEffects: None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user