Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac8a35811 | |||
| 9d3c6d87fb | |||
| 7df7f43e3c | |||
| 4c935a84e7 | |||
| 2ad0831560 | |||
| a45aba512a | |||
| d865f1e4a7 | |||
| a1a9d2f175 | |||
| 79492b6551 | |||
| 80fdb9a2f4 | |||
| a38c45cf75 | |||
| 975e75531d | |||
| 1b5f5bcdad | |||
| 8c00d96024 | |||
| bf8ccc8fc3 | |||
| 037d770f66 | |||
| dd50246672 | |||
| 090771674c | |||
| d8ab0208ba | |||
| a07e8272da | |||
| be82832eb1 | |||
| 67c8915d51 | |||
| 40b3ccb17c | |||
| 35c68863dc | |||
| 8bfee87bcf | |||
| e1383afbc3 | |||
| 4ce3b9094a | |||
| 0a4e196670 | |||
| 8d32a59f55 | |||
| 38b92f4251 | |||
| 88dbe85594 | |||
| f5003a7449 | |||
| a6810fa6ad | |||
| fc05d8d4eb | |||
| 1d6ef0e18e | |||
| dc0e223d1a | |||
| 932de79154 | |||
| fa625fed70 | |||
| f9fa1d95cb | |||
| 5615d54f81 | |||
| 8166bf768a | |||
| c3991c870d | |||
| 1a27619b39 | |||
| cc15aee405 | |||
| 53390d9885 | |||
| 0335b1a634 | |||
| bb362cd377 | |||
| 4405b109e3 | |||
| 47464a9cfa | |||
| 2b3fd94540 | |||
| 1bd46f3832 | |||
| 8a063fdf6a | |||
| 025dac5d8f | |||
| 0e5e754420 | |||
| 7a8e207985 | |||
| a4de0f2142 | |||
| 27716171bf | |||
| e5d7735d75 | |||
| 83ccb74d36 | |||
| 118957235d | |||
| 4a6406ed71 | |||
| 4bef974a89 | |||
| e497438085 | |||
| 74b3335b7d | |||
| 55c41212c8 | |||
| 4374ea08d3 | |||
| 436ecb80a3 | |||
| df9e9fca5a | |||
| add0e7d05c | |||
| 145194c87b | |||
| 6eafe0d2a8 | |||
| eeb2342509 | |||
| edfba4618a | |||
| 98751a3ee2 |
@@ -31,6 +31,8 @@ body:
|
||||
options:
|
||||
- Docker command in README
|
||||
- Development workflow
|
||||
- app.all-hands.dev
|
||||
- Other
|
||||
default: 0
|
||||
|
||||
- type: input
|
||||
|
||||
@@ -424,9 +424,9 @@ jobs:
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
|
||||
--name openhands-app-$SHORT_SHA \
|
||||
ghcr.io/all-hands-ai/runtime:$SHORT_SHA"
|
||||
docker.all-hands.dev/all-hands-ai/openhands:$SHORT_SHA"
|
||||
|
||||
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body)
|
||||
|
||||
|
||||
@@ -11,5 +11,5 @@ jobs:
|
||||
uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main
|
||||
if: github.event.label.name == 'fix-me'
|
||||
with:
|
||||
issue_number: ${{ github.event.issue.number }}
|
||||
max_iterations: 50
|
||||
secrets: inherit
|
||||
|
||||
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
### 9. Use existing Docker image
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
|
||||
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.9-nikolaik
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -38,15 +38,16 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
|
||||
system requirements and more information.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.9-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -32,7 +32,8 @@ workspace_base = "./workspace"
|
||||
# Enable saving and restoring the session when run from CLI
|
||||
#enable_cli_session = false
|
||||
|
||||
# Path to store trajectories
|
||||
# Path to store trajectories, can be a folder or a file
|
||||
# If it's a folder, the session id will be used as the file name
|
||||
#trajectories_path="./trajectories"
|
||||
|
||||
# File store path
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.9-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -161,7 +161,7 @@ Pour créer un workflow d'évaluation pour votre benchmark, suivez ces étapes :
|
||||
instruction=instruction,
|
||||
test_result=evaluation_result,
|
||||
metadata=metadata,
|
||||
history=state.history.compatibility_for_eval_history_pairs(),
|
||||
history=compatibility_for_eval_history_pairs(state.history),
|
||||
metrics=state.metrics.get() if state.metrics else None,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
)
|
||||
@@ -260,7 +260,7 @@ def codeact_user_response(state: State | None) -> str:
|
||||
# vérifier si l'agent a essayé de parler à l'utilisateur 3 fois, si oui, faire savoir à l'agent qu'il peut abandonner
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) >= 2:
|
||||
|
||||
@@ -158,7 +158,7 @@ OpenHands 的主要入口点在 `openhands/core/main.py` 中。以下是它工
|
||||
instruction=instruction,
|
||||
test_result=evaluation_result,
|
||||
metadata=metadata,
|
||||
history=state.history.compatibility_for_eval_history_pairs(),
|
||||
history=compatibility_for_eval_history_pairs(state.history),
|
||||
metrics=state.metrics.get() if state.metrics else None,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
)
|
||||
@@ -257,7 +257,7 @@ def codeact_user_response(state: State | None) -> str:
|
||||
# 检查代理是否已尝试与用户对话 3 次,如果是,让代理知道它可以放弃
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) >= 2:
|
||||
|
||||
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -59,7 +59,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--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.12 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ To create an evaluation workflow for your benchmark, follow these steps:
|
||||
instruction=instruction,
|
||||
test_result=evaluation_result,
|
||||
metadata=metadata,
|
||||
history=state.history.compatibility_for_eval_history_pairs(),
|
||||
history=compatibility_for_eval_history_pairs(state.history),
|
||||
metrics=state.metrics.get() if state.metrics else None,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
)
|
||||
@@ -257,7 +257,7 @@ def codeact_user_response(state: State | None) -> str:
|
||||
# check if the agent has tried to talk to the user 3 times, if so, let the agent know it can give up
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) >= 2:
|
||||
|
||||
@@ -19,6 +19,15 @@ OpenHands provides a user-friendly Graphical User Interface (GUI) mode for inter
|
||||
3. Enter the corresponding `API Key` for your chosen provider.
|
||||
4. Click "Save" to apply the settings.
|
||||
|
||||
### GitHub Token Setup
|
||||
|
||||
OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if it is available. This can happen in two ways:
|
||||
|
||||
1. Locally (OSS): The user directly inputs their GitHub token.
|
||||
2. Online (SaaS): The token is obtained through GitHub OAuth authentication.
|
||||
|
||||
When you reach the `/app` route, the app checks if a token is present. If it finds one, it sets it in the environment for the agent to use.
|
||||
|
||||
### Advanced Settings
|
||||
|
||||
1. Toggle `Advanced Options` to access additional settings.
|
||||
|
||||
@@ -44,15 +44,16 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
-e LLM_MODEL=$LLM_MODEL \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v $WORKSPACE_BASE:/opt/workspace_base \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--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.12 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
@@ -11,15 +11,16 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
```
|
||||
|
||||
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -4,11 +4,11 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
|
||||
|
||||
## Model Recommendations
|
||||
|
||||
Based on a recent evaluation of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. The full analysis can be found in [this blog article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed).
|
||||
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and [this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent).
|
||||
|
||||
When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings:
|
||||
|
||||
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 27% resolve rate with the default agent in OpenHands.
|
||||
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
|
||||
- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task.
|
||||
- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models.
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.5.2",
|
||||
"@docusaurus/plugin-content-pages": "^3.5.2",
|
||||
"@docusaurus/preset-classic": "^3.5.2",
|
||||
"@docusaurus/theme-mermaid": "^3.5.2",
|
||||
"@docusaurus/core": "^3.6.0",
|
||||
"@docusaurus/plugin-content-pages": "^3.6.0",
|
||||
"@docusaurus/preset-classic": "^3.6.0",
|
||||
"@docusaurus/theme-mermaid": "^3.6.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.5.2",
|
||||
"@docusaurus/tsconfig": "^3.6.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ from evaluation.EDA.game import Q20Game, Q20GameCelebrity
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -34,7 +35,8 @@ def codeact_user_response_eda(state: State) -> str:
|
||||
|
||||
# retrieve the latest model message from history
|
||||
if state.history:
|
||||
model_guess = state.history.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_guess = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
assert game is not None, 'Game is not initialized.'
|
||||
msg = game.generate_user_response(model_guess)
|
||||
@@ -139,7 +141,8 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
final_message = state.history.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
final_message = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}')
|
||||
test_result = game.reward()
|
||||
@@ -148,7 +151,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -16,6 +16,7 @@ from evaluation.agent_bench.helper import (
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -242,7 +243,7 @@ def process_instance(
|
||||
raw_ans = ''
|
||||
|
||||
# retrieve the last agent message or thought
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if event.source == 'agent':
|
||||
if isinstance(event, AgentFinishAction):
|
||||
raw_ans = event.thought
|
||||
@@ -271,7 +272,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from evaluation.aider_bench.helper import (
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -250,7 +251,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Save the output
|
||||
|
||||
@@ -13,6 +13,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -299,7 +300,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
test_result['generated'] = test_result['metadata']['1_copy_change_code']
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from tqdm import tqdm
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -46,7 +47,7 @@ def codeact_user_response(state: State) -> str:
|
||||
# check if the agent has tried to talk to the user 3 times, if so, let the agent know it can give up
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) > 2:
|
||||
@@ -431,7 +432,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -9,6 +9,7 @@ from datasets import load_dataset
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -89,7 +90,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# find the last delegate action
|
||||
last_delegate_action = None
|
||||
|
||||
@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -173,14 +174,14 @@ def initialize_runtime(runtime: Runtime, data_files: list[str]):
|
||||
|
||||
|
||||
def get_last_agent_finish_action(state: State) -> AgentFinishAction:
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if isinstance(event, AgentFinishAction):
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def get_last_message_action(state: State) -> MessageAction:
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if isinstance(event, MessageAction):
|
||||
return event
|
||||
return None
|
||||
@@ -307,7 +308,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# DiscoveryBench Evaluation
|
||||
eval_rec = run_eval_gold_vs_gen_NL_hypo_workflow(
|
||||
|
||||
@@ -12,6 +12,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -166,7 +167,7 @@ def process_instance(
|
||||
|
||||
model_answer_raw = ''
|
||||
# get the last message or thought from the agent
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if event.source == 'agent':
|
||||
if isinstance(event, AgentFinishAction):
|
||||
model_answer_raw = event.thought
|
||||
@@ -203,7 +204,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -101,7 +102,8 @@ def process_instance(
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# retrieve the last message from the agent
|
||||
model_answer_raw = state.history.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_answer_raw = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
# attempt to parse model_answer
|
||||
ast_eval_fn = instance['ast_eval']
|
||||
@@ -114,7 +116,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
output = EvalOutput(
|
||||
instance_id=instance_id,
|
||||
|
||||
@@ -28,6 +28,7 @@ from datasets import load_dataset
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -244,7 +245,7 @@ Ok now its time to start solving the question. Good luck!
|
||||
'C': False,
|
||||
'D': False,
|
||||
}
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if (
|
||||
isinstance(event, AgentFinishAction)
|
||||
and event.source != 'user'
|
||||
@@ -300,7 +301,7 @@ Ok now its time to start solving the question. Good luck!
|
||||
instance_id=str(instance.instance_id),
|
||||
instruction=instruction,
|
||||
metadata=metadata,
|
||||
history=state.history.compatibility_for_eval_history_pairs(),
|
||||
history=compatibility_for_eval_history_pairs(state.history),
|
||||
metrics=metrics,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
test_result={
|
||||
|
||||
@@ -21,6 +21,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -255,7 +256,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -129,7 +129,7 @@ def process_instance(
|
||||
# # result evaluation
|
||||
# # =============================================
|
||||
|
||||
histories = [event_to_dict(event) for event in state.history.get_events()]
|
||||
histories = [event_to_dict(event) for event in state.history]
|
||||
test_result: TestResult = test_class.verify_result(runtime, histories)
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -225,7 +226,7 @@ def process_instance(
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
final_message = ''
|
||||
for event in state.history.get_events(reverse=True):
|
||||
for event in reversed(state.history):
|
||||
if isinstance(event, AgentFinishAction):
|
||||
final_message = event.thought
|
||||
break
|
||||
@@ -247,7 +248,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -182,7 +183,7 @@ def process_instance(
|
||||
|
||||
# Instruction is the first message from the USER
|
||||
instruction = ''
|
||||
for event in state.history.get_events():
|
||||
for event in state.history:
|
||||
if isinstance(event, MessageAction):
|
||||
instruction = event.content
|
||||
break
|
||||
@@ -194,7 +195,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -13,6 +13,7 @@ from evaluation.mint.tasks import Task
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -28,6 +29,7 @@ from openhands.core.config import (
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
CmdRunAction,
|
||||
MessageAction,
|
||||
)
|
||||
@@ -45,7 +47,10 @@ def codeact_user_response_mint(state: State, task: Task, task_config: dict[str,
|
||||
task=task,
|
||||
task_config=task_config,
|
||||
)
|
||||
last_action = state.history.get_last_action()
|
||||
last_action = next(
|
||||
(event for event in reversed(state.history) if isinstance(event, Action)),
|
||||
None,
|
||||
)
|
||||
result_state: TaskState = env.step(last_action.message or '')
|
||||
|
||||
state.extra_data['task_state'] = result_state
|
||||
@@ -202,7 +207,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -24,6 +24,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -256,7 +257,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -232,7 +233,7 @@ If the program uses some packages that are incompatible, please figure out alter
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -83,6 +83,7 @@ def get_config(instance: pd.Series) -> AppConfig:
|
||||
timeout=1800,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -81,7 +81,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
|
||||
'</pr_description>\n\n'
|
||||
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <pr_description> are met?\n'
|
||||
"I've already taken care of all changes to any of the test files described in the <pr_description>. This means you DON'T have to modify the testing logic or any of the tests in any way!\n"
|
||||
'Your task is to make the minimal changes to non-tests files in the /repo directory to ensure the <pr_description> is satisfied.\n'
|
||||
'Your task is to make the minimal changes to non-tests files in the /workspace directory to ensure the <pr_description> is satisfied.\n'
|
||||
'Follow these steps to resolve the issue:\n'
|
||||
'1. As a first step, it might be a good idea to explore the repo to familiarize yourself with its structure.\n'
|
||||
'2. Create a script to reproduce the error and execute it with `python <filename.py>` using the BashTool, to confirm the error\n'
|
||||
@@ -146,6 +146,7 @@ def get_config(
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
@@ -443,7 +444,8 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
histories = [event_to_dict(event) for event in state.history.get_events()]
|
||||
# NOTE: this is NO LONGER the event stream, but an agent history that includes delegate agent's events
|
||||
histories = [event_to_dict(event) for event in state.history]
|
||||
metrics = state.metrics.get() if state.metrics else None
|
||||
|
||||
# Save the output
|
||||
|
||||
@@ -9,6 +9,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -126,7 +127,8 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# retrieve the last message from the agent
|
||||
model_answer_raw = state.history.get_last_agent_message()
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_answer_raw = last_agent_message.content if last_agent_message else ''
|
||||
|
||||
# attempt to parse model_answer
|
||||
correct = eval_answer(str(model_answer_raw), str(answer))
|
||||
@@ -137,7 +139,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -18,6 +18,9 @@ from openhands.core.logger import get_console_handler
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import Action
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.events.utils import get_pairs_from_events
|
||||
|
||||
|
||||
class EvalMetadata(BaseModel):
|
||||
@@ -112,7 +115,14 @@ def codeact_user_response(
|
||||
if state.history:
|
||||
# check if the last action has an answer, if so, early exit
|
||||
if try_parse is not None:
|
||||
last_action = state.history.get_last_action()
|
||||
last_action = next(
|
||||
(
|
||||
event
|
||||
for event in reversed(state.history)
|
||||
if isinstance(event, Action)
|
||||
),
|
||||
None,
|
||||
)
|
||||
ans = try_parse(last_action)
|
||||
if ans is not None:
|
||||
return '/exit'
|
||||
@@ -120,7 +130,7 @@ def codeact_user_response(
|
||||
# check if the agent has tried to talk to the user 3 times, if so, let the agent know it can give up
|
||||
user_msgs = [
|
||||
event
|
||||
for event in state.history.get_events()
|
||||
for event in state.history
|
||||
if isinstance(event, MessageAction) and event.source == 'user'
|
||||
]
|
||||
if len(user_msgs) >= 2:
|
||||
@@ -428,3 +438,18 @@ def update_llm_config_for_completions_logging(
|
||||
f'{llm_config.log_completions_folder}'
|
||||
)
|
||||
return llm_config
|
||||
|
||||
|
||||
# history is now available as a filtered stream of events, rather than list of pairs of (Action, Observation)
|
||||
# we rebuild the pairs here
|
||||
# for compatibility with the existing output format in evaluations
|
||||
# remove this when it's no longer necessary
|
||||
def compatibility_for_eval_history_pairs(
|
||||
history: list[Event],
|
||||
) -> list[tuple[dict, dict]]:
|
||||
history_pairs = []
|
||||
|
||||
for action, observation in get_pairs_from_events(history):
|
||||
history_pairs.append((event_to_dict(action), event_to_dict(observation)))
|
||||
|
||||
return history_pairs
|
||||
|
||||
@@ -10,6 +10,7 @@ import pandas as pd
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -166,7 +167,7 @@ def process_instance(
|
||||
|
||||
# Instruction is the first message from the USER
|
||||
instruction = ''
|
||||
for event in state.history.get_events():
|
||||
for event in state.history:
|
||||
if isinstance(event, MessageAction):
|
||||
instruction = event.content
|
||||
break
|
||||
@@ -178,7 +179,7 @@ def process_instance(
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
# remove when it becomes unnecessary
|
||||
histories = state.history.compatibility_for_eval_history_pairs()
|
||||
histories = compatibility_for_eval_history_pairs(state.history)
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
|
||||
@@ -2,3 +2,8 @@
|
||||
public/locales/**/*
|
||||
src/i18n/declaration.ts
|
||||
.env
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, afterEach, vi, it, expect } from "vitest";
|
||||
import { ChatInput } from "#/components/chat-input";
|
||||
|
||||
@@ -158,4 +158,46 @@ describe("ChatInput", () => {
|
||||
await user.tab();
|
||||
expect(onBlurMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should handle text paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Fire paste event with text data
|
||||
fireEvent.paste(input!, {
|
||||
clipboardData: {
|
||||
getData: (type: string) => type === 'text/plain' ? 'test paste' : '',
|
||||
files: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle image paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onImagePaste = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Create a paste event with an image file
|
||||
const file = new File(["dummy content"], "image.png", { type: "image/png" });
|
||||
|
||||
// Fire paste event with image data
|
||||
fireEvent.paste(input!, {
|
||||
clipboardData: {
|
||||
getData: () => '',
|
||||
files: [file]
|
||||
}
|
||||
});
|
||||
|
||||
// Verify image paste was handled
|
||||
expect(onImagePaste).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,156 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ChatInterface } from "#/components/chat-interface";
|
||||
import { SocketProvider } from "#/context/socket";
|
||||
import { addUserMessage } from "#/state/chatSlice";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import * as ChatSlice from "#/state/chatSlice";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
|
||||
render(<ChatInterface />, { wrapper: SocketProvider });
|
||||
renderWithProviders(<ChatInterface />);
|
||||
|
||||
describe("Empty state", () => {
|
||||
const { send: sendMock } = vi.hoisted(() => ({
|
||||
send: vi.fn(),
|
||||
}));
|
||||
|
||||
const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
|
||||
useWsClient: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("#/context/socket", async (importActual) => ({
|
||||
...(await importActual<typeof import("#/context/ws-client-provider")>()),
|
||||
useWsClient: useWsClientMock,
|
||||
}));
|
||||
});
|
||||
|
||||
describe.skip("ChatInterface", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.todo("should render suggestions if empty");
|
||||
it("should render suggestions if empty", () => {
|
||||
const { store } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("suggestions")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
store.dispatch(
|
||||
addUserMessage({
|
||||
content: "Hello",
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the default suggestions", () => {
|
||||
renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const repoSuggestions = Object.keys(SUGGESTIONS.repo);
|
||||
|
||||
// check that there are at most 4 suggestions displayed
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
expect(displayedSuggestions.length).toBeLessThanOrEqual(4);
|
||||
|
||||
// Check that each displayed suggestion is one of the repo suggestions
|
||||
displayedSuggestions.forEach((suggestion) => {
|
||||
expect(repoSuggestions).toContain(suggestion.textContent);
|
||||
});
|
||||
});
|
||||
|
||||
it.fails(
|
||||
"should load the a user message to the input when selecting",
|
||||
async () => {
|
||||
// this is to test that the message is in the UI before the socket is called
|
||||
useWsClientMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
runtimeActive: false, // mock an inactive runtime setup
|
||||
}));
|
||||
const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage");
|
||||
const user = userEvent.setup();
|
||||
const { store } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
const input = screen.getByTestId("chat-input");
|
||||
|
||||
await user.click(displayedSuggestions[0]);
|
||||
|
||||
// user message loaded to input
|
||||
expect(addUserMessageSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("suggestions")).toBeInTheDocument();
|
||||
expect(store.getState().chat.messages).toHaveLength(0);
|
||||
expect(input).toHaveValue(displayedSuggestions[0].textContent);
|
||||
},
|
||||
);
|
||||
|
||||
it.fails(
|
||||
"should send the message to the socket only if the runtime is active",
|
||||
async () => {
|
||||
useWsClientMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
runtimeActive: false, // mock an inactive runtime setup
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
chat: { messages: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
|
||||
await user.click(displayedSuggestions[0]);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
|
||||
useWsClientMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
runtimeActive: true, // mock an active runtime setup
|
||||
}));
|
||||
rerender(<ChatInterface />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendMock).toHaveBeenCalledWith(expect.any(String)),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skip("ChatInterface", () => {
|
||||
beforeAll(() => {
|
||||
// mock useScrollToBottom hook
|
||||
vi.mock("#/hooks/useScrollToBottom", () => ({
|
||||
useScrollToBottom: vi.fn(() => ({
|
||||
scrollDomToBottom: vi.fn(),
|
||||
onChatBodyScroll: vi.fn(),
|
||||
hitBottom: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render messages", () => {
|
||||
const messages: Message[] = [
|
||||
@@ -128,14 +265,14 @@ describe.skip("ChatInterface", () => {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
error: "Woops!",
|
||||
error: true,
|
||||
id: "",
|
||||
message: "Something went wrong",
|
||||
},
|
||||
];
|
||||
renderChatInterface(messages);
|
||||
|
||||
const error = screen.getByTestId("error-message");
|
||||
expect(within(error).getByText("Woops!")).toBeInTheDocument();
|
||||
expect(within(error).getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,21 @@ describe("InteractiveChatBox", () => {
|
||||
within(chatBox).getByTestId("upload-image-input");
|
||||
});
|
||||
|
||||
it.fails("should set custom values", () => {
|
||||
render(
|
||||
<InteractiveChatBox
|
||||
onSubmit={onSubmitMock}
|
||||
onStop={onStopMock}
|
||||
value="Hello, world!"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chatBox = screen.getByTestId("interactive-chat-box");
|
||||
const chatInput = within(chatBox).getByTestId("chat-input");
|
||||
|
||||
expect(chatInput).toHaveValue("Hello, world!");
|
||||
});
|
||||
|
||||
it("should display the image previews when images are uploaded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SuggestionItem } from "#/components/suggestion-item";
|
||||
|
||||
describe("SuggestionItem", () => {
|
||||
const suggestionItem = { label: "suggestion1", value: "a long text value" };
|
||||
const onClick = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render a suggestion", () => {
|
||||
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
|
||||
|
||||
expect(screen.getByTestId("suggestion")).toBeInTheDocument();
|
||||
expect(screen.getByText(/suggestion1/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicking a suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
|
||||
|
||||
const suggestion = screen.getByTestId("suggestion");
|
||||
await user.click(suggestion);
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith("a long text value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Suggestions } from "#/components/suggestions";
|
||||
|
||||
describe("Suggestions", () => {
|
||||
const firstSuggestion = {
|
||||
label: "first-suggestion",
|
||||
value: "value-of-first-suggestion",
|
||||
};
|
||||
const secondSuggestion = {
|
||||
label: "second-suggestion",
|
||||
value: "value-of-second-suggestion",
|
||||
};
|
||||
const suggestions = [firstSuggestion, secondSuggestion];
|
||||
|
||||
const onSuggestionClickMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render suggestions", () => {
|
||||
render(
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSuggestionClick={onSuggestionClickMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("suggestions")).toBeInTheDocument();
|
||||
const suggestionElements = screen.getAllByTestId("suggestion");
|
||||
|
||||
expect(suggestionElements).toHaveLength(2);
|
||||
expect(suggestionElements[0]).toHaveTextContent("first-suggestion");
|
||||
expect(suggestionElements[1]).toHaveTextContent("second-suggestion");
|
||||
});
|
||||
|
||||
it("should call onSuggestionClick when clicking a suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSuggestionClick={onSuggestionClickMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const suggestionElements = screen.getAllByTestId("suggestion");
|
||||
|
||||
await user.click(suggestionElements[0]);
|
||||
expect(onSuggestionClickMock).toHaveBeenCalledWith(
|
||||
"value-of-first-suggestion",
|
||||
);
|
||||
|
||||
await user.click(suggestionElements[1]);
|
||||
expect(onSuggestionClickMock).toHaveBeenCalledWith(
|
||||
"value-of-second-suggestion",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach } from "node:test";
|
||||
import { useTerminal } from "#/hooks/useTerminal";
|
||||
import { SocketProvider } from "#/context/socket";
|
||||
import { Command } from "#/state/commandSlice";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
@@ -18,6 +19,17 @@ function TestTerminalComponent({
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
interface WrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
function Wrapper({children}: WrapperProps) {
|
||||
return (
|
||||
<WsClientProvider enabled={true} token="NO_JWT" ghToken="NO_GITHUB" settings={null}>{children}</WsClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
const mockTerminal = vi.hoisted(() => ({
|
||||
loadAddon: vi.fn(),
|
||||
@@ -50,7 +62,7 @@ describe("useTerminal", () => {
|
||||
|
||||
it("should render", () => {
|
||||
render(<TestTerminalComponent commands={[]} secrets={[]} />, {
|
||||
wrapper: SocketProvider,
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +73,7 @@ describe("useTerminal", () => {
|
||||
];
|
||||
|
||||
render(<TestTerminalComponent commands={commands} secrets={[]} />, {
|
||||
wrapper: SocketProvider,
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
|
||||
@@ -85,7 +97,7 @@ describe("useTerminal", () => {
|
||||
secrets={[secret, anotherSecret]}
|
||||
/>,
|
||||
{
|
||||
wrapper: SocketProvider,
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import store from "../src/store";
|
||||
import { setInitialQuery, clearInitialQuery } from "../src/state/initial-query-slice";
|
||||
|
||||
describe("Initial Query Behavior", () => {
|
||||
it("should clear initial query when clearInitialQuery is dispatched", () => {
|
||||
// Set up initial query in the store
|
||||
store.dispatch(setInitialQuery("test query"));
|
||||
expect(store.getState().initalQuery.initialQuery).toBe("test query");
|
||||
|
||||
// Clear the initial query
|
||||
store.dispatch(clearInitialQuery());
|
||||
|
||||
// Verify initial query is cleared
|
||||
expect(store.getState().initalQuery.initialQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("App", () => {
|
||||
it.todo("should render");
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach } from "node:test";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cache } from "#/utils/cache";
|
||||
|
||||
describe("Cache", () => {
|
||||
const testKey = "key";
|
||||
const testData = { message: "Hello, world!" };
|
||||
const testTTL = 1000; // 1 second
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("gets data from memory if not expired", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
|
||||
expect(cache.get(testKey)).toEqual(testData);
|
||||
});
|
||||
|
||||
it("should expire after 5 minutes by default", () => {
|
||||
cache.set(testKey, testData);
|
||||
expect(cache.get(testKey)).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if cached data is expired", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
|
||||
vi.advanceTimersByTime(testTTL + 1);
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes data from memory", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.delete(testKey);
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears all data with the app prefix from memory", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.set("anotherKey", { data: "More data" }, testTTL);
|
||||
cache.clearAll();
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
expect(cache.get("anotherKey")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -59,9 +59,9 @@ describe("extractModelAndProvider", () => {
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("claude-3-5-sonnet-20241022")).toEqual({
|
||||
expect(extractModelAndProvider("claude-3-5-sonnet-20240620")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
model: "claude-3-5-sonnet-20240620",
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ test("organizeModelsAndProviders", () => {
|
||||
"gpt-4o",
|
||||
"together-ai-21.1b-41b",
|
||||
"gpt-4o-mini",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-2",
|
||||
"claude-2.1",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
@@ -46,6 +46,7 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.2",
|
||||
"@remix-run/dev": "^2.11.2",
|
||||
"@remix-run/testing": "^2.11.2",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
@@ -62,6 +63,7 @@
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
@@ -3379,6 +3381,21 @@
|
||||
"url": "https://opencollective.com/unts"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.48.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.2.tgz",
|
||||
"integrity": "sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.48.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.28",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
|
||||
@@ -7907,6 +7924,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "src/bin/cross-env.js",
|
||||
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
@@ -19422,6 +19457,50 @@
|
||||
"pathe": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.48.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.2.tgz",
|
||||
"integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.48.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.48.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz",
|
||||
"integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -45,11 +45,12 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run make-i18n && VITE_MOCK_API=false remix vite:dev",
|
||||
"dev:mock": "npm run make-i18n && VITE_MOCK_API=true remix vite:dev",
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false remix vite:dev",
|
||||
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true remix vite:dev",
|
||||
"build": "npm run make-i18n && tsc && remix vite:build",
|
||||
"start": "npx sirv-cli build/ --single",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:coverage": "npm run make-i18n && vitest run --coverage",
|
||||
"dev_wsl": "VITE_WATCH_USE_POLLING=true vite",
|
||||
"preview": "vite preview",
|
||||
@@ -71,6 +72,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.2",
|
||||
"@remix-run/dev": "^2.11.2",
|
||||
"@remix-run/testing": "^2.11.2",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
@@ -87,6 +89,7 @@
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1:3000",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "npm run dev:mock -- --port 3000",
|
||||
url: "http://127.0.0.1:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
@@ -122,6 +122,9 @@ export const retrieveGitHubUser = async (
|
||||
id: data.id,
|
||||
login: data.login,
|
||||
avatar_url: data.avatar_url,
|
||||
company: data.company,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
return user;
|
||||
@@ -136,33 +139,6 @@ export const retrieveGitHubUser = async (
|
||||
return error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a GitHub token and a repository name, creates a repository for the authenticated user
|
||||
* @param token The GitHub token
|
||||
* @param repositoryName Name of the repository to create
|
||||
* @param description Description of the repository
|
||||
* @param isPrivate Boolean indicating if the repository should be private
|
||||
* @returns The created repository or an error response
|
||||
*/
|
||||
export const createGitHubRepository = async (
|
||||
token: string,
|
||||
repositoryName: string,
|
||||
description?: string,
|
||||
isPrivate = true,
|
||||
): Promise<GitHubRepository | GitHubErrorReponse> => {
|
||||
const response = await fetch("https://api.github.com/user/repos", {
|
||||
method: "POST",
|
||||
headers: generateGitHubAPIHeaders(token),
|
||||
body: JSON.stringify({
|
||||
name: repositoryName,
|
||||
description,
|
||||
private: isPrivate,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const retrieveLatestGitHubCommit = async (
|
||||
token: string,
|
||||
repository: string,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { request } from "#/services/api";
|
||||
import { cache } from "#/utils/cache";
|
||||
import {
|
||||
SaveFileSuccessResponse,
|
||||
FileUploadSuccessResponse,
|
||||
@@ -15,7 +16,13 @@ class OpenHands {
|
||||
* @returns List of models available
|
||||
*/
|
||||
static async getModels(): Promise<string[]> {
|
||||
return request("/api/options/models");
|
||||
const cachedData = cache.get<string[]>("models");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request("/api/options/models");
|
||||
cache.set("models", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,7 +30,13 @@ class OpenHands {
|
||||
* @returns List of agents available
|
||||
*/
|
||||
static async getAgents(): Promise<string[]> {
|
||||
return request(`/api/options/agents`);
|
||||
const cachedData = cache.get<string[]>("agents");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request(`/api/options/agents`);
|
||||
cache.set("agents", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,11 +44,23 @@ class OpenHands {
|
||||
* @returns List of security analyzers available
|
||||
*/
|
||||
static async getSecurityAnalyzers(): Promise<string[]> {
|
||||
return request(`/api/options/security-analyzers`);
|
||||
const cachedData = cache.get<string[]>("agents");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request(`/api/options/security-analyzers`);
|
||||
cache.set("security-analyzers", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getConfig(): Promise<GetConfigResponse> {
|
||||
return request("/config.json");
|
||||
const cachedData = cache.get<GetConfigResponse>("config");
|
||||
if (cachedData) return cachedData;
|
||||
|
||||
const data = await request("/config.json");
|
||||
cache.set("config", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import PlayIcon from "#/assets/play";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
|
||||
import { RootState } from "#/store";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
const IgnoreTaskStateMap: Record<string, AgentState[]> = {
|
||||
[AgentState.PAUSED]: [
|
||||
@@ -72,7 +72,7 @@ function ActionButton({
|
||||
}
|
||||
|
||||
function AgentControlBar() {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const handleAction = (action: AgentState) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import AgentState from "#/types/AgentState";
|
||||
@@ -16,7 +17,7 @@ enum IndicatorColor {
|
||||
}
|
||||
|
||||
function AgentStatusBar() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const { curStatusMessage } = useSelector((state: RootState) => state.status);
|
||||
|
||||
@@ -94,15 +95,27 @@ function AgentStatusBar() {
|
||||
const [statusMessage, setStatusMessage] = React.useState<string>("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (curAgentState === AgentState.LOADING) {
|
||||
const trimmedCustomMessage = curStatusMessage.status.trim();
|
||||
if (trimmedCustomMessage) {
|
||||
setStatusMessage(t(trimmedCustomMessage));
|
||||
return;
|
||||
let message = curStatusMessage.message || "";
|
||||
if (curStatusMessage?.id) {
|
||||
const id = curStatusMessage.id.trim();
|
||||
if (i18n.exists(id)) {
|
||||
message = t(curStatusMessage.id.trim()) || message;
|
||||
}
|
||||
}
|
||||
if (curStatusMessage?.type === "error") {
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (curAgentState === AgentState.LOADING && message.trim()) {
|
||||
setStatusMessage(message);
|
||||
} else {
|
||||
setStatusMessage(AgentStatusMap[curAgentState].message);
|
||||
}
|
||||
}, [curStatusMessage.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setStatusMessage(AgentStatusMap[curAgentState].message);
|
||||
}, [curAgentState, curStatusMessage.status]);
|
||||
}, [curAgentState]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
|
||||
@@ -25,7 +25,11 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
<SyntaxHighlighter language="python" style={atomOneDark}>
|
||||
<SyntaxHighlighter
|
||||
language="python"
|
||||
style={atomOneDark}
|
||||
wrapLongLines
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</pre>
|
||||
@@ -78,7 +82,11 @@ function JupyterCell({ cell }: IJupyterCell): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function JupyterEditor(): JSX.Element {
|
||||
interface JupyterEditorProps {
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { cells } = useSelector((state: RootState) => state.jupyter);
|
||||
@@ -88,7 +96,7 @@ function JupyterEditor(): JSX.Element {
|
||||
useScrollToBottom(jupyterRef);
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="flex-1" style={{ maxWidth }}>
|
||||
<div
|
||||
className="overflow-y-auto h-full"
|
||||
ref={jupyterRef}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Clip from "#/assets/clip.svg?react";
|
||||
import Clip from "#/icons/clip.svg?react";
|
||||
|
||||
export function AttachImageLabel() {
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
interface ModalButtonProps {
|
||||
testId?: string;
|
||||
variant?: "default" | "text-like";
|
||||
onClick?: () => void;
|
||||
text: string;
|
||||
@@ -13,6 +14,7 @@ interface ModalButtonProps {
|
||||
}
|
||||
|
||||
function ModalButton({
|
||||
testId,
|
||||
variant = "default",
|
||||
onClick,
|
||||
text,
|
||||
@@ -24,6 +26,7 @@ function ModalButton({
|
||||
}: ModalButtonProps) {
|
||||
return (
|
||||
<button
|
||||
data-testid={testId}
|
||||
type={type === "submit" ? "submit" : "button"}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import ArrowSendIcon from "#/assets/arrow-send.svg?react";
|
||||
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ChatInputProps {
|
||||
@@ -16,6 +16,7 @@ interface ChatInputProps {
|
||||
onChange?: (message: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onImagePaste?: (files: File[]) => void;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
}
|
||||
|
||||
@@ -32,9 +33,51 @@ export function ChatInput({
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onImagePaste,
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
|
||||
|
||||
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/"),
|
||||
);
|
||||
// Only prevent default if we found image files to handle
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onImagePaste(files);
|
||||
}
|
||||
}
|
||||
// For text paste, let the default behavior handle it
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.types.includes("Files")) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
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 (files.length > 0) {
|
||||
onImagePaste(files);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitMessage = () => {
|
||||
if (textareaRef.current?.value) {
|
||||
@@ -67,12 +110,20 @@ export function ChatInput({
|
||||
onChange={handleChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPaste={handlePaste}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
value={value}
|
||||
minRows={1}
|
||||
maxRows={maxRows}
|
||||
data-dragging-over={isDraggingOver}
|
||||
className={cn(
|
||||
"grow text-sm self-center placeholder:text-neutral-400 text-white resize-none bg-transparent outline-none ring-0",
|
||||
"transition-[height] duration-200 ease-in-out",
|
||||
"grow text-sm self-center placeholder:text-neutral-400 text-white resize-none outline-none ring-0",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
isDraggingOver
|
||||
? "bg-neutral-600/50 rounded-lg px-2"
|
||||
: "bg-transparent",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React from "react";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import posthog from "posthog-js";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { FeedbackActions } from "./feedback-actions";
|
||||
@@ -18,13 +18,17 @@ import ConfirmationButtons from "./chat/ConfirmationButtons";
|
||||
import { ErrorMessage } from "./error-message";
|
||||
import { ContinueButton } from "./continue-button";
|
||||
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
|
||||
import { Suggestions } from "./suggestions";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import BuildIt from "#/icons/build-it.svg?react";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
const isErrorMessage = (
|
||||
message: Message | ErrorMessage,
|
||||
): message is ErrorMessage => "error" in message;
|
||||
|
||||
export function ChatInterface() {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const dispatch = useDispatch();
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
|
||||
@@ -37,17 +41,23 @@ export function ChatInterface() {
|
||||
"positive" | "negative"
|
||||
>("positive");
|
||||
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
|
||||
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
|
||||
|
||||
const handleSendMessage = async (content: string, files: File[]) => {
|
||||
posthog.capture("user_message_sent", {
|
||||
current_message_count: messages.length,
|
||||
});
|
||||
const promises = files.map((file) => convertImageToBase64(file));
|
||||
const imageUrls = await Promise.all(promises);
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
dispatch(addUserMessage({ content, imageUrls, timestamp }));
|
||||
send(createChatMessage(content, imageUrls, timestamp));
|
||||
setMessageToSend(null);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
posthog.capture("stop_button_clicked");
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
@@ -64,6 +74,28 @@ export function ChatInterface() {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
|
||||
<div className="flex flex-col items-center p-4 bg-neutral-700 rounded-xl w-full">
|
||||
<BuildIt width={45} height={54} />
|
||||
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
|
||||
Let's start building!
|
||||
</span>
|
||||
</div>
|
||||
<Suggestions
|
||||
suggestions={Object.entries(SUGGESTIONS.repo)
|
||||
.slice(0, 4)
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
onSuggestionClick={(value) => {
|
||||
setMessageToSend(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
@@ -73,7 +105,7 @@ export function ChatInterface() {
|
||||
isErrorMessage(message) ? (
|
||||
<ErrorMessage
|
||||
key={index}
|
||||
error={message.error}
|
||||
id={message.id}
|
||||
message={message.message}
|
||||
/>
|
||||
) : (
|
||||
@@ -123,6 +155,8 @@ export function ChatInterface() {
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
|
||||
value={messageToSend ?? undefined}
|
||||
onChange={setMessageToSend}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import RejectIcon from "#/assets/reject";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
interface ActionTooltipProps {
|
||||
type: "confirm" | "reject";
|
||||
@@ -37,7 +37,7 @@ function ActionTooltip({ type, onClick }: ActionTooltipProps) {
|
||||
|
||||
function ConfirmationButtons() {
|
||||
const { t } = useTranslation();
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
|
||||
const handleStateChange = (state: AgentState) => {
|
||||
const event = generateAgentStateChangeEvent(state);
|
||||
|
||||
@@ -6,6 +6,7 @@ type Message = {
|
||||
};
|
||||
|
||||
type ErrorMessage = {
|
||||
error: string;
|
||||
error: boolean;
|
||||
id?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ErrorMessageProps {
|
||||
error: string;
|
||||
id?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function ErrorMessage({ error, message }: ErrorMessageProps) {
|
||||
export function ErrorMessage({ id, message }: ErrorMessageProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showDetails, setShowDetails] = useState(true);
|
||||
const [headline, setHeadline] = useState("");
|
||||
const [details, setDetails] = useState(message);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && i18n.exists(id)) {
|
||||
setHeadline(t(id));
|
||||
setDetails(message);
|
||||
setShowDetails(false);
|
||||
}
|
||||
}, [id, message, i18n.language]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center justify-start border-l-2 border-danger pl-2 my-2 py-2">
|
||||
<div className="text-sm leading-4 flex flex-col gap-2">
|
||||
<p className="text-danger font-bold">{error}</p>
|
||||
<p className="text-neutral-300">{message}</p>
|
||||
{headline && <p className="text-danger font-bold">{headline}</p>}
|
||||
{headline && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="cursor-pointer text-left"
|
||||
>
|
||||
{showDetails
|
||||
? t("ERROR_MESSAGE$HIDE_DETAILS")
|
||||
: t("ERROR_MESSAGE$SHOW_DETAILS")}
|
||||
</button>
|
||||
)}
|
||||
{showDetails && <p className="text-neutral-300">{details}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useRouteLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { addErrorMessage, addUserMessage } from "#/state/chatSlice";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import {
|
||||
getCloneRepoCommand,
|
||||
getGitHubTokenCommand,
|
||||
} from "#/services/terminalService";
|
||||
import {
|
||||
clearFiles,
|
||||
clearSelectedRepository,
|
||||
setImportedProjectZip,
|
||||
} from "#/state/initial-query-slice";
|
||||
import { clientLoader as appClientLoader } from "#/routes/_oh.app";
|
||||
import store, { RootState } from "#/store";
|
||||
import { createChatMessage } from "#/services/chatService";
|
||||
import { clientLoader as rootClientLoader } from "#/routes/_oh";
|
||||
import { isGitHubErrorReponse } from "#/api/github";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { base64ToBlob } from "#/utils/base64-to-blob";
|
||||
import { setCurrentAgentState } from "#/state/agentSlice";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { getSettings } from "#/services/settings";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const isServerError = (data: object): data is ServerError => "error" in data;
|
||||
|
||||
const isErrorObservation = (data: object): data is ErrorObservation =>
|
||||
"observation" in data && data.observation === "error";
|
||||
|
||||
export function EventHandler({ children }: React.PropsWithChildren) {
|
||||
const { events, status, send } = useWsClient();
|
||||
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
|
||||
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
|
||||
const fetcher = useFetcher();
|
||||
const dispatch = useDispatch();
|
||||
const { files, importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initalQuery,
|
||||
);
|
||||
const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
|
||||
const initialQueryRef = React.useRef<string | null>(
|
||||
store.getState().initalQuery.initialQuery,
|
||||
);
|
||||
|
||||
const sendInitialQuery = (query: string, base64Files: string[]) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
send(createChatMessage(query, base64Files, timestamp));
|
||||
};
|
||||
const data = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
|
||||
const userId = React.useMemo(() => {
|
||||
if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
|
||||
return null;
|
||||
}, [data?.user]);
|
||||
const userSettings = getSettings();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
const event = events[events.length - 1];
|
||||
if (event.token) {
|
||||
fetcher.submit({ token: event.token as string }, { method: "post" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isServerError(event)) {
|
||||
if (event.error_code === 401) {
|
||||
toast.error("Session expired.");
|
||||
fetcher.submit({}, { method: "POST", action: "/end-session" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.error === "string") {
|
||||
toast.error(event.error);
|
||||
} else {
|
||||
toast.error(event.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
dispatch(
|
||||
addErrorMessage({
|
||||
id: event.extras?.error_id,
|
||||
message: event.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleAssistantMessage(event);
|
||||
}, [events.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (statusRef.current === status) {
|
||||
return; // This is a check because of strict mode - if the status did not change, don't do anything
|
||||
}
|
||||
statusRef.current = status;
|
||||
const initialQuery = initialQueryRef.current;
|
||||
|
||||
if (status === WsClientProviderStatus.ACTIVE) {
|
||||
let additionalInfo = "";
|
||||
if (ghToken && repo) {
|
||||
send(getCloneRepoCommand(ghToken, repo));
|
||||
additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
|
||||
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
|
||||
}
|
||||
// if there's an uploaded project zip, add it to the chat
|
||||
else if (importedProjectZip) {
|
||||
additionalInfo = `Files have been uploaded. Please check the /workspace for files.`;
|
||||
}
|
||||
|
||||
if (initialQuery) {
|
||||
if (additionalInfo) {
|
||||
sendInitialQuery(`${initialQuery}\n\n[${additionalInfo}]`, files);
|
||||
} else {
|
||||
sendInitialQuery(initialQuery, files);
|
||||
}
|
||||
dispatch(clearFiles()); // reset selected files
|
||||
initialQueryRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === WsClientProviderStatus.OPENING && initialQuery) {
|
||||
dispatch(
|
||||
addUserMessage({
|
||||
content: initialQuery,
|
||||
imageUrls: files,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (status === WsClientProviderStatus.STOPPED) {
|
||||
store.dispatch(setCurrentAgentState(AgentState.STOPPED));
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (runtimeActive && userId && ghToken) {
|
||||
// Export if the user valid, this could happen mid-session so it is handled here
|
||||
send(getGitHubTokenCommand(ghToken));
|
||||
}
|
||||
}, [userId, ghToken, runtimeActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (runtimeActive && importedProjectZip) {
|
||||
// upload files action
|
||||
try {
|
||||
const blob = base64ToBlob(importedProjectZip);
|
||||
const file = new File([blob], "imported-project.zip", {
|
||||
type: blob.type,
|
||||
});
|
||||
await OpenHands.uploadFiles([file]);
|
||||
dispatch(setImportedProjectZip(null));
|
||||
} catch (error) {
|
||||
toast.error("Failed to upload project files.");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [runtimeActive, importedProjectZip]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (userSettings.LLM_API_KEY) {
|
||||
posthog.capture("user_activated");
|
||||
}
|
||||
}, [userSettings.LLM_API_KEY]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface CustomInputProps {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -13,12 +16,19 @@ export function CustomInput({
|
||||
defaultValue,
|
||||
type = "text",
|
||||
}: CustomInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label htmlFor={name} className="flex flex-col gap-2">
|
||||
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3]">
|
||||
{label}
|
||||
{required && <span className="text-[#FF4D4F]">*</span>}
|
||||
{!required && <span className="text-[#A3A3A3]"> (optional)</span>}
|
||||
{!required && (
|
||||
<span className="text-[#A3A3A3]">
|
||||
{" "}
|
||||
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
id={name}
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
Switch,
|
||||
} from "@nextui-org/react";
|
||||
import { useFetcher, useLocation, useNavigate } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
|
||||
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
|
||||
import { clientAction } from "#/routes/settings";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
|
||||
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
|
||||
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
|
||||
import { clientAction } from "#/routes/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
import { DangerModal } from "../modals/confirmation-modals/danger-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@@ -35,6 +37,7 @@ export function SettingsForm({
|
||||
}: SettingsFormProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fetcher = useFetcher<typeof clientAction>();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
@@ -161,7 +164,7 @@ export function SettingsForm({
|
||||
label: "text-[#A3A3A3] text-xs",
|
||||
}}
|
||||
>
|
||||
Advanced Options
|
||||
{t(I18nKey.SETTINGS_FORM$ADVANCED_OPTIONS_LABEL)}
|
||||
</Switch>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
@@ -171,7 +174,7 @@ export function SettingsForm({
|
||||
htmlFor="custom-model"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Custom Model
|
||||
{t(I18nKey.SETTINGS_FORM$CUSTOM_MODEL_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={disabled}
|
||||
@@ -190,7 +193,7 @@ export function SettingsForm({
|
||||
htmlFor="base-url"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Base URL
|
||||
{t(I18nKey.SETTINGS_FORM$BASE_URL_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={disabled}
|
||||
@@ -220,7 +223,7 @@ export function SettingsForm({
|
||||
htmlFor="api-key"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
API Key
|
||||
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={disabled}
|
||||
@@ -234,14 +237,14 @@ export function SettingsForm({
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-[#A3A3A3]">
|
||||
Don't know your API key?{" "}
|
||||
{t(I18nKey.SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL)}{" "}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Click here for instructions
|
||||
{t(I18nKey.SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL)}
|
||||
</a>
|
||||
</p>
|
||||
</fieldset>
|
||||
@@ -255,7 +258,7 @@ export function SettingsForm({
|
||||
htmlFor="agent"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Agent
|
||||
{t(I18nKey.SETTINGS_FORM$AGENT_LABEL)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
isDisabled={disabled}
|
||||
@@ -291,7 +294,7 @@ export function SettingsForm({
|
||||
htmlFor="security-analyzer"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
Security Analyzer (Optional)
|
||||
{t(I18nKey.SETTINGS_FORM$SECURITY_ANALYZER_LABEL)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
isDisabled={disabled}
|
||||
@@ -334,7 +337,7 @@ export function SettingsForm({
|
||||
label: "text-[#A3A3A3] text-xs",
|
||||
}}
|
||||
>
|
||||
Enable Confirmation Mode
|
||||
{t(I18nKey.SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL)}
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
@@ -345,18 +348,18 @@ export function SettingsForm({
|
||||
<ModalButton
|
||||
disabled={disabled || fetcher.state === "submitting"}
|
||||
type="submit"
|
||||
text="Save"
|
||||
text={t(I18nKey.SETTINGS_FORM$SAVE_LABEL)}
|
||||
className="bg-[#4465DB] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
text="Close"
|
||||
text={t(I18nKey.SETTINGS_FORM$CLOSE_LABEL)}
|
||||
className="bg-[#737373] w-full"
|
||||
onClick={handleCloseClick}
|
||||
/>
|
||||
</div>
|
||||
<ModalButton
|
||||
disabled={disabled}
|
||||
text="Reset to defaults"
|
||||
text={t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL)}
|
||||
variant="text-like"
|
||||
className="text-danger self-start"
|
||||
onClick={() => {
|
||||
@@ -369,15 +372,17 @@ export function SettingsForm({
|
||||
{confirmResetDefaultsModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
title="Are you sure?"
|
||||
description="All saved information in your AI settings will be deleted including any API keys."
|
||||
title={t(I18nKey.SETTINGS_FORM$ARE_YOU_SURE_LABEL)}
|
||||
description={t(
|
||||
I18nKey.SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE,
|
||||
)}
|
||||
buttons={{
|
||||
danger: {
|
||||
text: "Reset Defaults",
|
||||
text: t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL),
|
||||
onClick: handleConfirmResetSettings,
|
||||
},
|
||||
cancel: {
|
||||
text: "Cancel",
|
||||
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
|
||||
onClick: () => setConfirmResetDefaultsModalOpen(false),
|
||||
},
|
||||
}}
|
||||
@@ -387,12 +392,17 @@ export function SettingsForm({
|
||||
{confirmEndSessionModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
title="End Session"
|
||||
description="Changing your settings will clear your workspace and start a new session. Are you sure you want to continue?"
|
||||
title={t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL)}
|
||||
description={t(
|
||||
I18nKey.SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE,
|
||||
)}
|
||||
buttons={{
|
||||
danger: { text: "End Session", onClick: handleConfirmEndSession },
|
||||
danger: {
|
||||
text: t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL),
|
||||
onClick: handleConfirmEndSession,
|
||||
},
|
||||
cancel: {
|
||||
text: "Cancel",
|
||||
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
|
||||
onClick: () => setConfirmEndSessionModalOpen(false),
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CloseIcon from "#/assets/close.svg?react";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ImagePreviewProps {
|
||||
|
||||
@@ -9,6 +9,8 @@ interface InteractiveChatBoxProps {
|
||||
mode?: "stop" | "submit";
|
||||
onSubmit: (message: string, images: File[]) => void;
|
||||
onStop: () => void;
|
||||
value?: string;
|
||||
onChange?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function InteractiveChatBox({
|
||||
@@ -16,6 +18,8 @@ export function InteractiveChatBox({
|
||||
mode = "submit",
|
||||
onSubmit,
|
||||
onStop,
|
||||
value,
|
||||
onChange,
|
||||
}: InteractiveChatBoxProps) {
|
||||
const [images, setImages] = React.useState<File[]>([]);
|
||||
|
||||
@@ -53,6 +57,13 @@ export function InteractiveChatBox({
|
||||
className={cn(
|
||||
"flex items-end gap-1",
|
||||
"bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
|
||||
"transition-colors duration-200",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
"group relative",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
|
||||
"before:border-2 before:border-dashed before:border-transparent",
|
||||
"[&:has(*:focus-within)]:before:border-neutral-500/50",
|
||||
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
|
||||
)}
|
||||
>
|
||||
<UploadImageInput onUpload={handleUpload} />
|
||||
@@ -60,8 +71,11 @@ export function InteractiveChatBox({
|
||||
disabled={isDisabled}
|
||||
button={mode}
|
||||
placeholder="What do you want to build?"
|
||||
onChange={onChange}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
value={value}
|
||||
onImagePaste={handleUpload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseModalTitle } from "./confirmation-modals/BaseModal";
|
||||
import ModalBody from "./ModalBody";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
@@ -9,6 +10,7 @@ import { clientLoader } from "#/routes/_oh";
|
||||
import { clientAction as settingsClientAction } from "#/routes/settings";
|
||||
import { clientAction as loginClientAction } from "#/routes/login";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface AccountSettingsModalProps {
|
||||
onClose: () => void;
|
||||
@@ -23,6 +25,7 @@ function AccountSettingsModal({
|
||||
gitHubError,
|
||||
analyticsConsent,
|
||||
}: AccountSettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
|
||||
const settingsFetcher = useFetcher<typeof settingsClientAction>({
|
||||
key: "settings",
|
||||
@@ -86,13 +89,13 @@ function AccountSettingsModal({
|
||||
/>
|
||||
{gitHubError && (
|
||||
<p className="text-danger text-xs">
|
||||
GitHub token is invalid. Please try again.
|
||||
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{data?.ghToken && !gitHubError && (
|
||||
<ModalButton
|
||||
variant="text-like"
|
||||
text="Disconnect"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
|
||||
onClick={() => {
|
||||
settingsFetcher.submit(
|
||||
{},
|
||||
@@ -122,11 +125,11 @@ function AccountSettingsModal({
|
||||
}
|
||||
type="submit"
|
||||
intent="account"
|
||||
text="Save"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)}
|
||||
className="bg-[#4465DB]"
|
||||
/>
|
||||
<ModalButton
|
||||
text="Close"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$CLOSE)}
|
||||
onClick={onClose}
|
||||
className="bg-[#737373]"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
@@ -7,10 +8,11 @@ import ModalButton from "../buttons/ModalButton";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { CustomInput } from "../form/custom-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function ConnectToGitHubByTokenModal() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBody testID="auth-modal">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -29,13 +31,18 @@ function ConnectToGitHubByTokenModal() {
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[#A3A3A3]">
|
||||
By connecting you agree to our{" "}
|
||||
<span className="text-hyperlink">terms of service</span>.
|
||||
{t(
|
||||
I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$BY_CONNECTING_YOU_AGREE,
|
||||
)}{" "}
|
||||
<span className="text-hyperlink">
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE)}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</label>
|
||||
<ModalButton
|
||||
type="submit"
|
||||
text="Continue"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_BY_TOKEN_MODAL$CONTINUE)}
|
||||
className="bg-[#791B80] w-full"
|
||||
disabled={navigation.state === "loading"}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import LoadingSpinnerOuter from "#/assets/loading-outer.svg?react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoadingSpinnerOuter from "#/icons/loading-outer.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size: "small" | "large";
|
||||
@@ -28,10 +30,12 @@ interface LoadingProjectModalProps {
|
||||
}
|
||||
|
||||
function LoadingProjectModal({ message }: LoadingProjectModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
<span className="text-xl leading-6 -tracking-[0.01em] font-semibold">
|
||||
{message || "Loading..."}
|
||||
{message || t(I18nKey.LOADING_PROJECT$LOADING)}
|
||||
</span>
|
||||
<LoadingSpinner size="large" />
|
||||
</ModalBody>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { CustomInput } from "../form/custom-input";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "./confirmation-modals/BaseModal";
|
||||
import { clientLoader } from "#/routes/_oh";
|
||||
import { clientAction } from "#/routes/login";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConnectToGitHubModalProps {
|
||||
onClose: () => void;
|
||||
@@ -16,6 +18,7 @@ interface ConnectToGitHubModalProps {
|
||||
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
|
||||
const fetcher = useFetcher<typeof clientAction>({ key: "login" });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
@@ -24,14 +27,14 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
<BaseModalDescription
|
||||
description={
|
||||
<span>
|
||||
Get your token{" "}
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
here
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
@@ -53,14 +56,15 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
testId="connect-to-github"
|
||||
type="submit"
|
||||
text="Connect"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)}
|
||||
disabled={fetcher.state === "submitting"}
|
||||
className="bg-[#791B80] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
onClick={onClose}
|
||||
text="Close"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CLOSE)}
|
||||
className="bg-[#737373] w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SecurityInvariant from "./invariant/Invariant";
|
||||
import BaseModal from "../base-modal/BaseModal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SecurityProps {
|
||||
isOpen: boolean;
|
||||
@@ -17,11 +19,13 @@ const SecurityAnalyzers: Record<SecurityAnalyzerOption, React.ElementType> = {
|
||||
};
|
||||
|
||||
function Security({ isOpen, onOpenChange, securityAnalyzer }: SecurityProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const AnalyzerComponent =
|
||||
securityAnalyzer &&
|
||||
SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
|
||||
? SecurityAnalyzers[securityAnalyzer as SecurityAnalyzerOption]
|
||||
: () => <div>Unknown security analyzer chosen</div>;
|
||||
: () => <div>{t(I18nKey.SECURITY$UNKNOWN_ANALYZER_LABEL)}</div>;
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
|
||||
@@ -123,7 +123,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
|
||||
async function exportTraces(): Promise<void> {
|
||||
const data = await request(`/api/security/export-trace`);
|
||||
toast.info("Trace exported");
|
||||
toast.info(t(I18nKey.INVARIANT$TRACE_EXPORTED_MESSAGE));
|
||||
|
||||
const filename = `openhands-trace-${getFormattedDateTime()}.json`;
|
||||
downloadJSON(data, filename);
|
||||
@@ -134,7 +134,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ policy }),
|
||||
});
|
||||
toast.info("Policy updated");
|
||||
toast.info(t(I18nKey.INVARIANT$POLICY_UPDATED_MESSAGE));
|
||||
}
|
||||
|
||||
async function updateSettings(): Promise<void> {
|
||||
@@ -143,7 +143,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
toast.info("Settings updated");
|
||||
toast.info(t(I18nKey.INVARIANT$SETTINGS_UPDATED_MESSAGE));
|
||||
}
|
||||
|
||||
const handleExportTraces = useCallback(() => {
|
||||
@@ -162,9 +162,9 @@ function SecurityInvariant(): JSX.Element {
|
||||
logs: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">Logs</h2>
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$LOG_LABEL)}</h2>
|
||||
<Button onClick={handleExportTraces} className="bg-neutral-700">
|
||||
Export Trace
|
||||
{t(I18nKey.INVARIANT$EXPORT_TRACE_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 p-4 max-h-screen overflow-y-auto" ref={logsRef}>
|
||||
@@ -195,9 +195,9 @@ function SecurityInvariant(): JSX.Element {
|
||||
policy: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">Policy</h2>
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$POLICY_LABEL)}</h2>
|
||||
<Button className="bg-neutral-700" onClick={handleUpdatePolicy}>
|
||||
Update Policy
|
||||
{t(I18nKey.INVARIANT$UPDATE_POLICY_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex grow items-center justify-center">
|
||||
@@ -214,14 +214,16 @@ function SecurityInvariant(): JSX.Element {
|
||||
settings: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">Settings</h2>
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$SETTINGS_LABEL)}</h2>
|
||||
<Button className="bg-neutral-700" onClick={handleUpdateSettings}>
|
||||
Update Settings
|
||||
{t(I18nKey.INVARIANT$UPDATE_SETTINGS_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex grow p-4">
|
||||
<div className="flex flex-col w-full">
|
||||
<p className="mb-2">Ask for user confirmation on risk severity:</p>
|
||||
<p className="mb-2">
|
||||
{t(I18nKey.INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL)}
|
||||
</p>
|
||||
<Select
|
||||
placeholder="Select risk severity"
|
||||
value={selectedRisk}
|
||||
@@ -264,7 +266,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
key={ActionSecurityRisk.HIGH + 1}
|
||||
aria-label="Don't ask for confirmation"
|
||||
>
|
||||
Don't ask for confirmation
|
||||
{t(I18nKey.INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL)}
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -278,18 +280,17 @@ function SecurityInvariant(): JSX.Element {
|
||||
<div className="w-60 bg-neutral-800 border-r border-r-neutral-600 p-4 flex-shrink-0">
|
||||
<div className="text-center mb-2">
|
||||
<InvariantLogoIcon className="mx-auto mb-1" />
|
||||
<b>Invariant Analyzer</b>
|
||||
<b>{t(I18nKey.INVARIANT$INVARIANT_ANALYZER_LABEL)}</b>
|
||||
</div>
|
||||
<p className="text-[0.6rem]">
|
||||
Invariant Analyzer continuously monitors your OpenHands agent for
|
||||
security issues.{" "}
|
||||
{t(I18nKey.INVARIANT$INVARIANT_ANALYZER_MESSAGE)}{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://github.com/invariantlabs-ai/invariant"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Click to learn more
|
||||
{t(I18nKey.INVARIANT$CLICK_TO_LEARN_MORE_LABEL)}
|
||||
</a>
|
||||
</p>
|
||||
<hr className="border-t border-neutral-600 my-2" />
|
||||
@@ -298,19 +299,19 @@ function SecurityInvariant(): JSX.Element {
|
||||
className={`cursor-pointer p-2 rounded ${activeSection === "logs" && "bg-neutral-600"}`}
|
||||
onClick={() => setActiveSection("logs")}
|
||||
>
|
||||
Logs
|
||||
{t(I18nKey.INVARIANT$LOG_LABEL)}
|
||||
</div>
|
||||
<div
|
||||
className={`cursor-pointer p-2 rounded ${activeSection === "policy" && "bg-neutral-600"}`}
|
||||
onClick={() => setActiveSection("policy")}
|
||||
>
|
||||
Policy
|
||||
{t(I18nKey.INVARIANT$POLICY_LABEL)}
|
||||
</div>
|
||||
<div
|
||||
className={`cursor-pointer p-2 rounded ${activeSection === "settings" && "bg-neutral-600"}`}
|
||||
onClick={() => setActiveSection("settings")}
|
||||
>
|
||||
Settings
|
||||
{t(I18nKey.INVARIANT$SETTINGS_LABEL)}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import EllipsisH from "#/assets/ellipsis-h.svg?react";
|
||||
import posthog from "posthog-js";
|
||||
import EllipsisH from "#/icons/ellipsis-h.svg?react";
|
||||
import { ModalBackdrop } from "../modals/modal-backdrop";
|
||||
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
|
||||
import { addUserMessage } from "#/state/chatSlice";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { createChatMessage } from "#/services/chatService";
|
||||
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
|
||||
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
|
||||
import { ProjectMenuDetails } from "./project-menu-details";
|
||||
import { downloadWorkspace } from "#/utils/download-workspace";
|
||||
import { LoadingSpinner } from "../modals/LoadingProject";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
interface ProjectMenuCardProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -25,24 +27,23 @@ export function ProjectMenuCard({
|
||||
isConnectedToGitHub,
|
||||
githubData,
|
||||
}: ProjectMenuCardProps) {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [working, setWorking] = React.useState(false);
|
||||
|
||||
const toggleMenuVisibility = () => {
|
||||
setContextMenuIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handlePushToGitHub = () => {
|
||||
posthog.capture("push_to_github_button_clicked");
|
||||
const rawEvent = {
|
||||
content: `
|
||||
Let's push the code to GitHub.
|
||||
If we're currently on the openhands-workspace branch, please create a new branch with a descriptive name.
|
||||
Commit any changes and push them to the remote repository.
|
||||
Finally, open up a pull request using the GitHub API and the token in the GITHUB_TOKEN environment variable, then show me the URL of the pull request.
|
||||
Please push the changes to GitHub and open a pull request.
|
||||
`,
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -58,20 +59,27 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
|
||||
setContextMenuIsOpen(false);
|
||||
};
|
||||
|
||||
const handleDownloadWorkspace = () => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
try {
|
||||
setWorking(true);
|
||||
downloadWorkspace().then(
|
||||
() => setWorking(false),
|
||||
() => setWorking(false),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
|
||||
{contextMenuIsOpen && (
|
||||
{!working && contextMenuIsOpen && (
|
||||
<ProjectMenuCardContextMenu
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
onPushToGitHub={handlePushToGitHub}
|
||||
onDownloadWorkspace={() => {
|
||||
try {
|
||||
downloadWorkspace();
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
}}
|
||||
onDownloadWorkspace={handleDownloadWorkspace}
|
||||
onClose={() => setContextMenuIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -93,7 +101,11 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
|
||||
onClick={toggleMenuVisibility}
|
||||
aria-label="Open project menu"
|
||||
>
|
||||
<EllipsisH width={36} height={36} />
|
||||
{working ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
<EllipsisH width={36} height={36} />
|
||||
)}
|
||||
</button>
|
||||
{connectToGitHubModalOpen && (
|
||||
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import CloudConnection from "#/assets/cloud-connection.svg?react";
|
||||
import CloudConnection from "#/icons/cloud-connection.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsPlaceholderProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -10,9 +12,13 @@ export function ProjectMenuDetailsPlaceholder({
|
||||
isConnectedToGitHub,
|
||||
onConnectToGitHub,
|
||||
}: ProjectMenuDetailsPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm leading-6 font-semibold">New Project</span>
|
||||
<span className="text-sm leading-6 font-semibold">
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnectToGitHub}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import ExternalLinkIcon from "#/assets/external-link.svg?react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ExternalLinkIcon from "#/icons/external-link.svg?react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuDetailsProps {
|
||||
repoName: string;
|
||||
@@ -12,6 +14,7 @@ export function ProjectMenuDetails({
|
||||
avatar,
|
||||
lastCommit,
|
||||
}: ProjectMenuDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
@@ -32,7 +35,8 @@ export function ProjectMenuDetails({
|
||||
>
|
||||
<span>{lastCommit.sha.slice(-7)}</span> <span>·</span>{" "}
|
||||
<span>
|
||||
{formatTimeDelta(new Date(lastCommit.commit.author.date))} ago
|
||||
{formatTimeDelta(new Date(lastCommit.commit.author.date))}{" "}
|
||||
{t(I18nKey.PROJECT_MENU_DETAILS$AGO_LABEL)}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClickOutsideElement } from "#/hooks/useClickOutsideElement";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuCardContextMenuProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -18,7 +20,7 @@ export function ProjectMenuCardContextMenu({
|
||||
onClose,
|
||||
}: ProjectMenuCardContextMenuProps) {
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
@@ -26,16 +28,16 @@ export function ProjectMenuCardContextMenu({
|
||||
>
|
||||
{!isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onConnectToGitHub}>
|
||||
Connect to GitHub
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onPushToGitHub}>
|
||||
Push to GitHub
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
<ContextMenuListItem onClick={onDownloadWorkspace}>
|
||||
Download as .zip
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ArrowSendIcon from "#/assets/arrow-send.svg?react";
|
||||
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
|
||||
|
||||
interface ScrollToBottomButtonProps {
|
||||
onClick: () => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Lightbulb from "#/assets/lightbulb.svg?react";
|
||||
import Refresh from "#/assets/refresh.svg?react";
|
||||
import Lightbulb from "#/icons/lightbulb.svg?react";
|
||||
import Refresh from "#/icons/refresh.svg?react";
|
||||
|
||||
interface SuggestionBubbleProps {
|
||||
suggestion: string;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export type Suggestion = { label: string; value: string };
|
||||
|
||||
interface SuggestionItemProps {
|
||||
suggestion: Suggestion;
|
||||
onClick: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
|
||||
return (
|
||||
<li className="border border-neutral-600 rounded-xl hover:bg-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="suggestion"
|
||||
onClick={() => onClick(suggestion.value)}
|
||||
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-4 font-semibold"
|
||||
>
|
||||
{suggestion.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SuggestionItem, type Suggestion } from "./suggestion-item";
|
||||
|
||||
interface SuggestionsProps {
|
||||
suggestions: Suggestion[];
|
||||
onSuggestionClick: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Suggestions({
|
||||
suggestions,
|
||||
onSuggestionClick,
|
||||
}: SuggestionsProps) {
|
||||
return (
|
||||
<ul data-testid="suggestions" className="flex flex-col gap-4 w-full">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<SuggestionItem
|
||||
key={index}
|
||||
suggestion={suggestion}
|
||||
onClick={onSuggestionClick}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Clip from "#/assets/clip.svg?react";
|
||||
import Clip from "#/icons/clip.svg?react";
|
||||
|
||||
interface UploadImageInputProps {
|
||||
onUpload: (files: File[]) => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoadingSpinner } from "./modals/LoadingProject";
|
||||
import DefaultUserAvatar from "#/assets/default-user.svg?react";
|
||||
import DefaultUserAvatar from "#/icons/default-user.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface UserAvatarProps {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import React from "react";
|
||||
import { Data } from "ws";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
|
||||
interface WebSocketClientOptions {
|
||||
token: string | null;
|
||||
onOpen?: (event: Event) => void;
|
||||
onMessage?: (event: MessageEvent<Data>) => void;
|
||||
onError?: (event: Event) => void;
|
||||
onClose?: (event: Event) => void;
|
||||
}
|
||||
|
||||
interface WebSocketContextType {
|
||||
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
|
||||
start: (options?: WebSocketClientOptions) => void;
|
||||
stop: () => void;
|
||||
setRuntimeIsInitialized: () => void;
|
||||
runtimeActive: boolean;
|
||||
isConnected: boolean;
|
||||
events: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const SocketContext = React.createContext<WebSocketContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface SocketProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SocketProvider({ children }: SocketProviderProps) {
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
const [runtimeActive, setRuntimeActive] = React.useState(false);
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const setRuntimeIsInitialized = () => {
|
||||
setRuntimeActive(true);
|
||||
};
|
||||
|
||||
const start = React.useCallback((options?: WebSocketClientOptions): void => {
|
||||
if (wsRef.current) {
|
||||
EventLogger.warning(
|
||||
"WebSocket connection is already established, but a new one is starting anyways.",
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const sessionToken = options?.token || "NO_JWT"; // not allowed to be empty or duplicated
|
||||
const ghToken = localStorage.getItem("ghToken") || "NO_GITHUB";
|
||||
|
||||
const ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
|
||||
"openhands",
|
||||
sessionToken,
|
||||
ghToken,
|
||||
]);
|
||||
|
||||
ws.addEventListener("open", (event) => {
|
||||
setIsConnected(true);
|
||||
options?.onOpen?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
EventLogger.message(event);
|
||||
|
||||
setEvents((prevEvents) => [...prevEvents, JSON.parse(event.data)]);
|
||||
options?.onMessage?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
EventLogger.event(event, "SOCKET ERROR");
|
||||
options?.onError?.(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
EventLogger.event(event, "SOCKET CLOSE");
|
||||
|
||||
setIsConnected(false);
|
||||
setRuntimeActive(false);
|
||||
wsRef.current = null;
|
||||
options?.onClose?.(event);
|
||||
});
|
||||
|
||||
wsRef.current = ws;
|
||||
}, []);
|
||||
|
||||
const stop = React.useCallback((): void => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const send = React.useCallback(
|
||||
(data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
|
||||
if (!wsRef.current) {
|
||||
EventLogger.error("WebSocket is not connected.");
|
||||
return;
|
||||
}
|
||||
setEvents((prevEvents) => [...prevEvents, JSON.parse(data.toString())]);
|
||||
wsRef.current.send(data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
setRuntimeIsInitialized,
|
||||
runtimeActive,
|
||||
isConnected,
|
||||
events,
|
||||
}),
|
||||
[
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
setRuntimeIsInitialized,
|
||||
runtimeActive,
|
||||
isConnected,
|
||||
events,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useSocket() {
|
||||
const context = React.useContext(SocketContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { SocketProvider, useSocket };
|
||||
@@ -0,0 +1,175 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { Settings } from "#/services/settings";
|
||||
import ActionType from "#/types/ActionType";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import AgentState from "#/types/AgentState";
|
||||
|
||||
export enum WsClientProviderStatus {
|
||||
STOPPED,
|
||||
OPENING,
|
||||
ACTIVE,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
interface UseWsClient {
|
||||
status: WsClientProviderStatus;
|
||||
events: Record<string, unknown>[];
|
||||
send: (event: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const WsClientContext = React.createContext<UseWsClient>({
|
||||
status: WsClientProviderStatus.STOPPED,
|
||||
events: [],
|
||||
send: () => {
|
||||
throw new Error("not connected");
|
||||
},
|
||||
});
|
||||
|
||||
interface WsClientProviderProps {
|
||||
enabled: boolean;
|
||||
token: string | null;
|
||||
ghToken: string | null;
|
||||
settings: Settings | null;
|
||||
}
|
||||
|
||||
export function WsClientProvider({
|
||||
enabled,
|
||||
token,
|
||||
ghToken,
|
||||
settings,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
const tokenRef = React.useRef<string | null>(token);
|
||||
const ghTokenRef = React.useRef<string | null>(ghToken);
|
||||
const closeRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
if (!wsRef.current) {
|
||||
EventLogger.error("WebSocket is not connected.");
|
||||
return;
|
||||
}
|
||||
wsRef.current.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
setStatus(WsClientProviderStatus.OPENING);
|
||||
const initEvent = {
|
||||
action: ActionType.INIT,
|
||||
args: settings,
|
||||
};
|
||||
send(initEvent);
|
||||
}
|
||||
|
||||
function handleMessage(messageEvent: MessageEvent) {
|
||||
const event = JSON.parse(messageEvent.data);
|
||||
setEvents((prevEvents) => [...prevEvents, event]);
|
||||
if (event.extras?.agent_state === AgentState.INIT) {
|
||||
setStatus(WsClientProviderStatus.ACTIVE);
|
||||
}
|
||||
if (
|
||||
status !== WsClientProviderStatus.ACTIVE &&
|
||||
event?.observation === "error"
|
||||
) {
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setStatus(WsClientProviderStatus.STOPPED);
|
||||
setEvents([]);
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
function handleError(event: Event) {
|
||||
posthog.capture("socket_error");
|
||||
EventLogger.event(event, "SOCKET ERROR");
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
}
|
||||
|
||||
// Connect websocket
|
||||
React.useEffect(() => {
|
||||
let ws = wsRef.current;
|
||||
|
||||
// If disabled close any existing websockets...
|
||||
if (!enabled) {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
wsRef.current = null;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// If there is no websocket or the tokens have changed or the current websocket is closed,
|
||||
// create a new one
|
||||
if (
|
||||
!ws ||
|
||||
(tokenRef.current && token !== tokenRef.current) ||
|
||||
ghToken !== ghTokenRef.current ||
|
||||
ws.readyState === WebSocket.CLOSED ||
|
||||
ws.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
ws?.close();
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(`${protocol}//${baseUrl}/ws`, [
|
||||
"openhands",
|
||||
token || "NO_JWT",
|
||||
ghToken || "NO_GITHUB",
|
||||
]);
|
||||
}
|
||||
ws.addEventListener("open", handleOpen);
|
||||
ws.addEventListener("message", handleMessage);
|
||||
ws.addEventListener("error", handleError);
|
||||
ws.addEventListener("close", handleClose);
|
||||
wsRef.current = ws;
|
||||
tokenRef.current = token;
|
||||
ghTokenRef.current = ghToken;
|
||||
|
||||
return () => {
|
||||
ws.removeEventListener("open", handleOpen);
|
||||
ws.removeEventListener("message", handleMessage);
|
||||
ws.removeEventListener("error", handleError);
|
||||
ws.removeEventListener("close", handleClose);
|
||||
};
|
||||
}, [enabled, token, ghToken]);
|
||||
|
||||
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
|
||||
// before actually closing the socket and cancel the operation if the component gets remounted.
|
||||
React.useEffect(() => {
|
||||
const timeout = closeRef.current;
|
||||
if (timeout != null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return () => {
|
||||
closeRef.current = setTimeout(() => {
|
||||
wsRef.current?.close();
|
||||
}, 100);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo<UseWsClient>(
|
||||
() => ({
|
||||
status,
|
||||
events,
|
||||
send,
|
||||
}),
|
||||
[status, events],
|
||||
);
|
||||
|
||||
return (
|
||||
<WsClientContext.Provider value={value}>
|
||||
{children}
|
||||
</WsClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWsClient() {
|
||||
const context = React.useContext(WsClientContext);
|
||||
return context;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import React, { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { SocketProvider } from "./context/socket";
|
||||
import "./i18n";
|
||||
import store from "./store";
|
||||
|
||||
@@ -43,12 +42,10 @@ prepareApp().then(() =>
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<SocketProvider>
|
||||
<Provider store={store}>
|
||||
<RemixBrowser />
|
||||
<PosthogInit />
|
||||
</Provider>
|
||||
</SocketProvider>
|
||||
<Provider store={store}>
|
||||
<RemixBrowser />
|
||||
<PosthogInit />
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { Command } from "#/state/commandSlice";
|
||||
import { getTerminalCommand } from "#/services/terminalService";
|
||||
import { parseTerminalOutput } from "#/utils/parseTerminalOutput";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
|
||||
/*
|
||||
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
|
||||
@@ -15,7 +15,7 @@ export const useTerminal = (
|
||||
commands: Command[] = [],
|
||||
secrets: string[] = [],
|
||||
) => {
|
||||
const { send } = useSocket();
|
||||
const { send } = useWsClient();
|
||||
const terminal = React.useRef<Terminal | null>(null);
|
||||
const fitAddon = React.useRef<FitAddon | null>(null);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -535,7 +535,8 @@
|
||||
"pt": "Socket não inicializado",
|
||||
"ko-KR": "소켓이 초기화되지 않았습니다",
|
||||
"ar": "لم يتم تهيئة Socket",
|
||||
"tr": "Soket başlatılmadı"
|
||||
"tr": "Soket başlatılmadı",
|
||||
"no": "Socket ikke initialisert"
|
||||
},
|
||||
"EXPLORER$UPLOAD_ERROR_MESSAGE": {
|
||||
"en": "Error uploading file",
|
||||
@@ -548,7 +549,8 @@
|
||||
"pt": "Erro ao fazer upload do arquivo",
|
||||
"ko-KR": "파일 업로드 중 오류 발생",
|
||||
"ar": "خطأ في تحميل الملف",
|
||||
"tr": "Dosya yüklenirken hata oluştu"
|
||||
"tr": "Dosya yüklenirken hata oluştu",
|
||||
"no": "Feil ved opplasting av fil"
|
||||
},
|
||||
"EXPLORER$LABEL_DROP_FILES": {
|
||||
"en": "Drop files here",
|
||||
@@ -557,6 +559,7 @@
|
||||
"zh-TW": "將檔案拖曳至此",
|
||||
"es": "Suelta los archivos aquí",
|
||||
"fr": "Déposez les fichiers ici",
|
||||
"no": "Slipp filer her",
|
||||
"it": "Trascina i file qui",
|
||||
"pt": "Solte os arquivos aqui",
|
||||
"ko-KR": "파일을 여기에 놓으세요",
|
||||
@@ -574,7 +577,8 @@
|
||||
"pt": "Espaço de trabalho",
|
||||
"ko-KR": "작업 공간",
|
||||
"ar": "مساحة العمل",
|
||||
"tr": "Çalışma alanı"
|
||||
"tr": "Çalışma alanı",
|
||||
"no": "Arbeidsområde"
|
||||
},
|
||||
"EXPLORER$EMPTY_WORKSPACE_MESSAGE": {
|
||||
"en": "No files in workspace",
|
||||
@@ -587,7 +591,8 @@
|
||||
"pt": "Nenhum arquivo no espaço de trabalho",
|
||||
"ko-KR": "작업 공간에 파일이 없습니다",
|
||||
"ar": "لا توجد ملفات في مساحة العمل",
|
||||
"tr": "Çalışma alanında dosya yok"
|
||||
"tr": "Çalışma alanında dosya yok",
|
||||
"no": "Ingen filer i arbeidsområdet"
|
||||
},
|
||||
"EXPLORER$LOADING_WORKSPACE_MESSAGE": {
|
||||
"en": "Loading workspace...",
|
||||
@@ -600,7 +605,8 @@
|
||||
"pt": "Carregando espaço de trabalho...",
|
||||
"ko-KR": "작업 공간 로딩 중...",
|
||||
"ar": "جارٍ تحميل مساحة العمل...",
|
||||
"tr": "Çalışma alanı yükleniyor..."
|
||||
"tr": "Çalışma alanı yükleniyor...",
|
||||
"no": "Laster arbeidsområde..."
|
||||
},
|
||||
"EXPLORER$REFRESH_ERROR_MESSAGE": {
|
||||
"en": "Error refreshing workspace",
|
||||
@@ -613,7 +619,8 @@
|
||||
"pt": "Erro ao atualizar o espaço de trabalho",
|
||||
"ko-KR": "작업 공간 새로 고침 오류",
|
||||
"ar": "خطأ في تحديث مساحة العمل",
|
||||
"tr": "Çalışma alanı yenilenirken hata oluştu"
|
||||
"tr": "Çalışma alanı yenilenirken hata oluştu",
|
||||
"no": "Feil ved oppdatering av arbeidsområde"
|
||||
},
|
||||
"EXPLORER$UPLOAD_SUCCESS_MESSAGE": {
|
||||
"en": "Successfully uploaded {{count}} file(s)",
|
||||
@@ -626,7 +633,8 @@
|
||||
"pt": "{{count}} arquivo(s) carregado(s) com sucesso",
|
||||
"ko-KR": "{{count}}개의 파일을 성공적으로 업로드했습니다",
|
||||
"ar": "تم تحميل {{count}} ملف (ملفات) بنجاح",
|
||||
"tr": "{{count}} dosya başarıyla yüklendi"
|
||||
"tr": "{{count}} dosya başarıyla yüklendi",
|
||||
"no": "Lastet opp {{count}} fil(er) vellykket"
|
||||
},
|
||||
"EXPLORER$NO_FILES_UPLOADED_MESSAGE": {
|
||||
"en": "No files were uploaded",
|
||||
@@ -639,7 +647,8 @@
|
||||
"pt": "Nenhum arquivo foi carregado",
|
||||
"ko-KR": "업로드된 파일이 없습니다",
|
||||
"ar": "لم يتم تحميل أي ملفات",
|
||||
"tr": "Hiçbir dosya yüklenmedi"
|
||||
"tr": "Hiçbir dosya yüklenmedi",
|
||||
"no": "Ingen filer ble lastet opp"
|
||||
},
|
||||
"EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": {
|
||||
"en": "{{count}} file(s) were skipped during upload",
|
||||
@@ -652,7 +661,8 @@
|
||||
"pt": "{{count}} arquivo(s) foram ignorados durante o upload",
|
||||
"ko-KR": "업로드 중 {{count}}개의 파일이 건너뛰어졌습니다",
|
||||
"ar": "تم تخطي {{count}} ملف (ملفات) أثناء التحميل",
|
||||
"tr": "Yükleme sırasında {{count}} dosya atlandı"
|
||||
"tr": "Yükleme sırasında {{count}} dosya atlandı",
|
||||
"no": "{{count}} fil(er) ble hoppet over under opplasting"
|
||||
},
|
||||
"EXPLORER$UPLOAD_UNEXPECTED_RESPONSE_MESSAGE": {
|
||||
"en": "Unexpected response structure from server",
|
||||
@@ -665,7 +675,8 @@
|
||||
"pt": "Estrutura de resposta inesperada do servidor",
|
||||
"ko-KR": "서버로부터 예상치 못한 응답 구조",
|
||||
"ar": "بنية استجابة غير متوقعة من الخادم",
|
||||
"tr": "Sunucudan beklenmeyen yanıt yapısı"
|
||||
"tr": "Sunucudan beklenmeyen yanıt yapısı",
|
||||
"no": "Uventet responsstruktur fra serveren"
|
||||
},
|
||||
"LOAD_SESSION$MODAL_TITLE": {
|
||||
"en": "Return to existing session?",
|
||||
@@ -798,7 +809,326 @@
|
||||
"tr": "İptal"
|
||||
},
|
||||
"FEEDBACK$EMAIL_PLACEHOLDER": {
|
||||
"en": "Enter your email address."
|
||||
"en": "Enter your email address",
|
||||
"es": "Ingresa tu correo electrónico",
|
||||
"zh-CN": "输入您的电子邮件地址",
|
||||
"zh-TW": "輸入您的電子郵件地址",
|
||||
"ko-KR": "이메일 주소를 입력하세요",
|
||||
"no": "Skriv inn din e-postadresse",
|
||||
"ar": "أدخل عنوان بريدك الإلكتروني",
|
||||
"de": "Geben Sie Ihre E-Mail-Adresse ein",
|
||||
"fr": "Entrez votre adresse e-mail",
|
||||
"it": "Inserisci il tuo indirizzo email",
|
||||
"pt": "Digite seu endereço de e-mail",
|
||||
"tr": "E-posta adresinizi girin"
|
||||
},
|
||||
"FEEDBACK$PASSWORD_COPIED_MESSAGE": {
|
||||
"en": "Password copied to clipboard.",
|
||||
"es": "Contraseña copiada al portapapeles.",
|
||||
"zh-CN": "密码已复制到剪贴板。",
|
||||
"zh-TW": "密碼已複製到剪貼板。",
|
||||
"ko-KR": "비밀번호가 클립보드에 복사되었습니다.",
|
||||
"no": "Passord kopiert til utklippstavlen.",
|
||||
"ar": "تم نسخ كلمة المرور إلى الحافظة.",
|
||||
"de": "Passwort in die Zwischenablage kopiert.",
|
||||
"fr": "Mot de passe copié dans le presse-papiers.",
|
||||
"it": "Password copiata negli appunti.",
|
||||
"pt": "Senha copiada para a área de transferência.",
|
||||
"tr": "Parola panoya kopyalandı."
|
||||
},
|
||||
"FEEDBACK$GO_TO_FEEDBACK": {
|
||||
"en": "Go to shared feedback",
|
||||
"es": "Ir a feedback compartido",
|
||||
"zh-CN": "转到共享反馈",
|
||||
"zh-TW": "前往共享反饋",
|
||||
"ko-KR": "공유된 피드백으로 이동",
|
||||
"no": "Gå til delt tilbakemelding",
|
||||
"ar": "الذهاب إلى التعليقات المشتركة",
|
||||
"de": "Zum geteilten Feedback gehen",
|
||||
"fr": "Aller aux commentaires partagés",
|
||||
"it": "Vai al feedback condiviso",
|
||||
"pt": "Ir para feedback compartilhado",
|
||||
"tr": "Paylaşılan geri bildirimlere git"
|
||||
},
|
||||
"FEEDBACK$PASSWORD": {
|
||||
"en": "Password:",
|
||||
"es": "Contraseña:",
|
||||
"zh-CN": "密码:",
|
||||
"zh-TW": "密碼:",
|
||||
"ko-KR": "비밀번호:",
|
||||
"no": "Passord:",
|
||||
"ar": "كلمة المرور:",
|
||||
"de": "Passwort:",
|
||||
"fr": "Mot de passe :",
|
||||
"it": "Password:",
|
||||
"pt": "Senha:",
|
||||
"tr": "Parola:"
|
||||
},
|
||||
"FEEDBACK$INVALID_EMAIL_FORMAT": {
|
||||
"en": "Invalid email format",
|
||||
"es": "Formato de correo inválido",
|
||||
"zh-CN": "无效的电子邮件格式",
|
||||
"zh-TW": "無效的電子郵件格式",
|
||||
"ko-KR": "잘못된 이메일 형식",
|
||||
"no": "Ugyldig e-postformat",
|
||||
"ar": "تنسيق البريد الإلكتروني غير صالح",
|
||||
"de": "Ungültiges E-Mail-Format",
|
||||
"fr": "Format d'e-mail invalide",
|
||||
"it": "Formato email non valido",
|
||||
"pt": "Formato de e-mail inválido",
|
||||
"tr": "Geçersiz e-posta biçimi"
|
||||
},
|
||||
"FEEDBACK$FAILED_TO_SHARE": {
|
||||
"en": "Failed to share, please contact the developers:",
|
||||
"es": "Error al compartir, por favor contacta con los desarrolladores:",
|
||||
"zh-CN": "分享失败,请联系开发人员:",
|
||||
"zh-TW": "分享失敗,請聯繫開發人員:",
|
||||
"ko-KR": "공유 실패, 개발자에게 문의하세요:",
|
||||
"no": "Deling mislyktes, vennligst kontakt utviklerne:",
|
||||
"ar": "فشل المشاركة، يرجى الاتصال بالمطورين:",
|
||||
"de": "Teilen fehlgeschlagen, bitte kontaktieren Sie die Entwickler:",
|
||||
"fr": "Échec du partage, veuillez contacter les développeurs :",
|
||||
"it": "Condivisione fallita, contattare gli sviluppatori:",
|
||||
"pt": "Falha ao compartilhar, entre em contato com os desenvolvedores:",
|
||||
"tr": "Paylaşım başarısız, lütfen geliştiricilerle iletişime geçin:"
|
||||
},
|
||||
"FEEDBACK$COPY_LABEL": {
|
||||
"en": "Copy",
|
||||
"es": "Copiar",
|
||||
"zh-CN": "复制",
|
||||
"zh-TW": "複製",
|
||||
"ko-KR": "복사",
|
||||
"no": "Kopier",
|
||||
"ar": "نسخ",
|
||||
"de": "Kopieren",
|
||||
"fr": "Copier",
|
||||
"it": "Copia",
|
||||
"pt": "Copiar",
|
||||
"tr": "Kopyala"
|
||||
},
|
||||
"FEEDBACK$SHARING_SETTINGS_LABEL": {
|
||||
"en": "Sharing settings",
|
||||
"es": "Configuración de compartir",
|
||||
"zh-CN": "共享设置",
|
||||
"zh-TW": "共享設定",
|
||||
"ko-KR": "공유 설정",
|
||||
"no": "Delingsinnstillinger",
|
||||
"ar": "إعدادات المشاركة",
|
||||
"de": "Freigabeeinstellungen",
|
||||
"fr": "Paramètres de partage",
|
||||
"it": "Impostazioni di condivisione",
|
||||
"pt": "Configurações de compartilhamento",
|
||||
"tr": "Paylaşım ayarları"
|
||||
},
|
||||
"SECURITY$UNKNOWN_ANALYZER_LABEL":{
|
||||
"en": "Unknown security analyzer chosen",
|
||||
"es": "Analizador de seguridad desconocido",
|
||||
"zh-CN": "选择了未知的安全分析器",
|
||||
"zh-TW": "選擇了未知的安全分析器",
|
||||
"ko-KR": "알 수 없는 보안 분석기가 선택되었습니다",
|
||||
"no": "Ukjent sikkerhetsanalysator valgt",
|
||||
"ar": "تم اختيار محلل أمان غير معروف",
|
||||
"de": "Unbekannter Sicherheitsanalysator ausgewählt",
|
||||
"fr": "Analyseur de sécurité inconnu choisi",
|
||||
"it": "Analizzatore di sicurezza sconosciuto selezionato",
|
||||
"pt": "Analisador de segurança desconhecido escolhido",
|
||||
"tr": "Bilinmeyen güvenlik analizörü seçildi"
|
||||
},
|
||||
"INVARIANT$UPDATE_POLICY_LABEL": {
|
||||
"en": "Update Policy",
|
||||
"es": "Actualizar política",
|
||||
"zh-CN": "更新策略",
|
||||
"zh-TW": "更新策略",
|
||||
"ko-KR": "정책 업데이트",
|
||||
"no": "Oppdater policy",
|
||||
"ar": "تحديث السياسة",
|
||||
"de": "Richtlinie aktualisieren",
|
||||
"fr": "Mettre à jour la politique",
|
||||
"it": "Aggiorna policy",
|
||||
"pt": "Atualizar política",
|
||||
"tr": "İlkeyi güncelle"
|
||||
},
|
||||
"INVARIANT$UPDATE_SETTINGS_LABEL": {
|
||||
"en": "Update Settings",
|
||||
"es": "Actualizar configuración",
|
||||
"zh-CN": "更新设置",
|
||||
"zh-TW": "更新設定",
|
||||
"ko-KR": "설정 업데이트",
|
||||
"no": "Oppdater innstillinger",
|
||||
"ar": "تحديث الإعدادات",
|
||||
"de": "Einstellungen aktualisieren",
|
||||
"fr": "Mettre à jour les paramètres",
|
||||
"it": "Aggiorna impostazioni",
|
||||
"pt": "Atualizar configurações",
|
||||
"tr": "Ayarları güncelle"
|
||||
},
|
||||
"INVARIANT$SETTINGS_LABEL": {
|
||||
"en": "Settings",
|
||||
"es": "Configuración",
|
||||
"zh-CN": "设置",
|
||||
"zh-TW": "設定",
|
||||
"ko-KR": "설정",
|
||||
"no": "Innstillinger",
|
||||
"ar": "الإعدادات",
|
||||
"de": "Einstellungen",
|
||||
"fr": "Paramètres",
|
||||
"it": "Impostazioni",
|
||||
"pt": "Configurações",
|
||||
"tr": "Ayarlar"
|
||||
},
|
||||
"INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": {
|
||||
"en": "Ask for user confirmation on risk severity:",
|
||||
"es": "Preguntar por confirmación del usuario sobre severidad del riesgo:",
|
||||
"zh-CN": "询问用户确认风险等级:",
|
||||
"zh-TW": "詢問用戶確認風險等級:",
|
||||
"ko-KR": "위험 심각도에 대한 사용자 확인 요청:",
|
||||
"no": "Be om brukerbekreftelse på risikoalvorlighet:",
|
||||
"ar": "اطلب تأكيد المستخدم على مستوى الخطورة:",
|
||||
"de": "Nach Benutzerbestätigung für Risikoschweregrad fragen:",
|
||||
"fr": "Demander la confirmation de l'utilisateur sur la gravité du risque :",
|
||||
"it": "Chiedi conferma all'utente sulla gravità del rischio:",
|
||||
"pt": "Solicitar confirmação do usuário sobre a gravidade do risco:",
|
||||
"tr": "Risk şiddeti için kullanıcı onayı iste:"
|
||||
},
|
||||
"INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": {
|
||||
"en": "Don't ask for confirmation",
|
||||
"es": "No solicitar confirmación",
|
||||
"zh-CN": "不要请求确认",
|
||||
"zh-TW": "不要請求確認",
|
||||
"ko-KR": "확인 요청하지 않음",
|
||||
"no": "Ikke spør om bekreftelse",
|
||||
"ar": "لا تطلب التأكيد",
|
||||
"de": "Nicht nach Bestätigung fragen",
|
||||
"fr": "Ne pas demander de confirmation",
|
||||
"it": "Non chiedere conferma",
|
||||
"pt": "Não solicitar confirmação",
|
||||
"tr": "Onay isteme"
|
||||
},
|
||||
"INVARIANT$INVARIANT_ANALYZER_LABEL": {
|
||||
"en": "Invariant Analyzer",
|
||||
"es": "Analizador de invariantes",
|
||||
"zh-CN": "不变量分析器",
|
||||
"zh-TW": "不變量分析器",
|
||||
"ko-KR": "불변성 분석기",
|
||||
"no": "Invariant-analysator",
|
||||
"ar": "محلل الثوابت",
|
||||
"de": "Invarianten-Analysator",
|
||||
"fr": "Analyseur d'invariants",
|
||||
"it": "Analizzatore di invarianti",
|
||||
"pt": "Analisador de invariantes",
|
||||
"tr": "Değişmez Analizörü"
|
||||
},
|
||||
"INVARIANT$INVARIANT_ANALYZER_MESSAGE": {
|
||||
"en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.",
|
||||
"es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad.",
|
||||
"zh-CN": "不变量分析器持续监控您的 OpenHands 代理的安全问题。",
|
||||
"zh-TW": "不變量分析器持續監控您的 OpenHands 代理的安全問題。",
|
||||
"ko-KR": "불변성 분석기는 OpenHands 에이전트의 보안 문제를 지속적으로 모니터링합니다.",
|
||||
"no": "Invariant-analysatoren overvåker kontinuerlig OpenHands-agenten din for sikkerhetsproblemer.",
|
||||
"ar": "يراقب محلل الثوابت وكيل OpenHands الخاص بك باستمرار للتحقق من المشاكل الأمنية.",
|
||||
"de": "Der Invarianten-Analysator überwacht kontinuierlich Ihren OpenHands-Agenten auf Sicherheitsprobleme.",
|
||||
"fr": "L'analyseur d'invariants surveille en permanence votre agent OpenHands pour détecter les problèmes de sécurité.",
|
||||
"it": "L'analizzatore di invarianti monitora continuamente il tuo agente OpenHands per problemi di sicurezza.",
|
||||
"pt": "O analisador de invariantes monitora continuamente seu agente OpenHands em busca de problemas de segurança.",
|
||||
"tr": "Değişmez Analizörü, OpenHands ajanınızı güvenlik sorunları için sürekli olarak izler."
|
||||
},
|
||||
"INVARIANT$CLICK_TO_LEARN_MORE_LABEL": {
|
||||
"en": "Click to learn more",
|
||||
"es": "Clic para aprender más",
|
||||
"zh-CN": "点击了解更多",
|
||||
"zh-TW": "點擊了解更多",
|
||||
"ko-KR": "자세히 알아보기",
|
||||
"no": "Klikk for å lære mer",
|
||||
"ar": "انقر لمعرفة المزيد",
|
||||
"de": "Klicken Sie, um mehr zu erfahren",
|
||||
"fr": "Cliquez pour en savoir plus",
|
||||
"it": "Clicca per saperne di più",
|
||||
"pt": "Clique para saber mais",
|
||||
"tr": "Daha fazla bilgi için tıklayın"
|
||||
},
|
||||
"INVARIANT$POLICY_LABEL": {
|
||||
"en": "Policy",
|
||||
"es": "Política",
|
||||
"zh-CN": "策略",
|
||||
"zh-TW": "策略",
|
||||
"ko-KR": "정책",
|
||||
"no": "Policy",
|
||||
"ar": "السياسة",
|
||||
"de": "Richtlinie",
|
||||
"fr": "Politique",
|
||||
"it": "Policy",
|
||||
"pt": "Política",
|
||||
"tr": "İlke"
|
||||
},
|
||||
"INVARIANT$LOG_LABEL": {
|
||||
"en": "Logs",
|
||||
"es": "Logs",
|
||||
"zh-CN": "日志",
|
||||
"zh-TW": "日誌",
|
||||
"ko-KR": "로그",
|
||||
"no": "Logger",
|
||||
"ar": "السجلات",
|
||||
"de": "Protokolle",
|
||||
"fr": "Journaux",
|
||||
"it": "Log",
|
||||
"pt": "Logs",
|
||||
"tr": "Günlükler"
|
||||
},
|
||||
"INVARIANT$EXPORT_TRACE_LABEL": {
|
||||
"en": "Export Trace",
|
||||
"es": "Exportar traza",
|
||||
"zh-CN": "导出跟踪",
|
||||
"zh-TW": "匯出追蹤",
|
||||
"ko-KR": "추적 내보내기",
|
||||
"no": "Eksporter sporing",
|
||||
"ar": "تصدير التتبع",
|
||||
"de": "Ablaufverfolgung exportieren",
|
||||
"fr": "Exporter la trace",
|
||||
"it": "Esporta traccia",
|
||||
"pt": "Exportar rastreamento",
|
||||
"tr": "İzlemeyi dışa aktar"
|
||||
},
|
||||
"INVARIANT$TRACE_EXPORTED_MESSAGE": {
|
||||
"en": "Trace exported",
|
||||
"es": "Traza exportada",
|
||||
"zh-CN": "跟踪已导出",
|
||||
"zh-TW": "追蹤已匯出",
|
||||
"ko-KR": "추적 내보내기 완료",
|
||||
"no": "Sporing eksportert",
|
||||
"ar": "تم تصدير التتبع",
|
||||
"de": "Ablaufverfolgung exportiert",
|
||||
"fr": "Trace exportée",
|
||||
"it": "Traccia esportata",
|
||||
"pt": "Rastreamento exportado",
|
||||
"tr": "İzleme dışa aktarıldı"
|
||||
},
|
||||
"INVARIANT$POLICY_UPDATED_MESSAGE": {
|
||||
"en": "Policy updated",
|
||||
"es": "Política actualizada",
|
||||
"zh-CN": "策略已更新",
|
||||
"zh-TW": "策略已更新",
|
||||
"ko-KR": "정책이 업데이트되었습니다",
|
||||
"no": "Policy oppdatert",
|
||||
"ar": "تم تحديث السياسة",
|
||||
"de": "Richtlinie aktualisiert",
|
||||
"fr": "Politique mise à jour",
|
||||
"it": "Policy aggiornata",
|
||||
"pt": "Política atualizada",
|
||||
"tr": "İlke güncellendi"
|
||||
},
|
||||
"INVARIANT$SETTINGS_UPDATED_MESSAGE": {
|
||||
"en": "Settings updated",
|
||||
"es": "Configuración actualizada",
|
||||
"zh-CN": "设置已更新",
|
||||
"zh-TW": "設定已更新",
|
||||
"ko-KR": "설정이 업데이트되었습니다",
|
||||
"no": "Innstillinger oppdatert",
|
||||
"ar": "تم تحديث الإعدادات",
|
||||
"de": "Einstellungen aktualisiert",
|
||||
"fr": "Paramètres mis à jour",
|
||||
"it": "Impostazioni aggiornate",
|
||||
"pt": "Configurações atualizadas",
|
||||
"tr": "Ayarlar güncellendi"
|
||||
},
|
||||
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
|
||||
"en": "Starting up!",
|
||||
@@ -1187,7 +1517,8 @@
|
||||
"pt": "Conversa de chat",
|
||||
"es": "Conversación de chat",
|
||||
"ar": "محادثة تلقيم",
|
||||
"fr": "Conversation de chat"
|
||||
"fr": "Conversation de chat",
|
||||
"tr": "Sohbet Konuşması"
|
||||
},
|
||||
"CHAT_INTERFACE$UNKNOWN_SENDER": {
|
||||
"en": "Unknown",
|
||||
@@ -1441,6 +1772,14 @@
|
||||
"fr": "Privé",
|
||||
"tr": "Özel"
|
||||
},
|
||||
"ERROR_MESSAGE$SHOW_DETAILS": {
|
||||
"en": "Show details",
|
||||
"es": "Mostrar detalles"
|
||||
},
|
||||
"ERROR_MESSAGE$HIDE_DETAILS": {
|
||||
"en": "Hide details",
|
||||
"es": "Ocultar detalles"
|
||||
},
|
||||
"STATUS$STARTING_RUNTIME": {
|
||||
"en": "Starting Runtime...",
|
||||
"zh-CN": "启动运行时...",
|
||||
@@ -1510,5 +1849,158 @@
|
||||
"ar": "في انتظار جاهزية العميل...",
|
||||
"fr": "En attente que le client soit prêt...",
|
||||
"tr": "İstemcinin hazır olması bekleniyor..."
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$DISCONNECT":{
|
||||
"en": "Disconnect",
|
||||
"es": "Desconectar"
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$SAVE":{
|
||||
"en": "Save",
|
||||
"es": "Guardar"
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$CLOSE":{
|
||||
"en": "Close",
|
||||
"es": "Cerrar"
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{
|
||||
"en": "GitHub token is invalid. Please try again.",
|
||||
"es": ""
|
||||
},
|
||||
"CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN": {
|
||||
"en": "Get your token",
|
||||
"es": "Obten tu token"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_MODAL$HERE": {
|
||||
"en": "here",
|
||||
"es": "aquí"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_MODAL$CONNECT": {
|
||||
"en": "Connect",
|
||||
"es": "Conectar"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_MODAL$CLOSE": {
|
||||
"en": "Close",
|
||||
"es": "Cerrar"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$BY_CONNECTING_YOU_AGREE": {
|
||||
"en": "By connecting you agree to our",
|
||||
"es": "Al conectarte tu aceptas nuestros"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE": {
|
||||
"en": "terms of service",
|
||||
"es": "términos de servicio"
|
||||
},
|
||||
"CONNECT_TO_GITHUB_BY_TOKEN_MODAL$CONTINUE": {
|
||||
"en": "Continue",
|
||||
"es": "Continuar"
|
||||
},
|
||||
"LOADING_PROJECT$LOADING": {
|
||||
"en": "Loading...",
|
||||
"es": "Cargando..."
|
||||
},
|
||||
"CUSTOM_INPUT$OPTIONAL_LABEL": {
|
||||
"en": "(Optional)",
|
||||
"es": "(Opcional)"
|
||||
},
|
||||
"SETTINGS_FORM$ADVANCED_OPTIONS_LABEL": {
|
||||
"en": "Advanced Options",
|
||||
"es": "Opciones avanzadas"
|
||||
},
|
||||
"SETTINGS_FORM$CUSTOM_MODEL_LABEL": {
|
||||
"en": "Custom Model",
|
||||
"es": "Modelo personalizado"
|
||||
},
|
||||
"SETTINGS_FORM$BASE_URL_LABEL": {
|
||||
"en": "Base URL",
|
||||
"es": "URL base"
|
||||
},
|
||||
"SETTINGS_FORM$API_KEY_LABEL": {
|
||||
"en": "API Key",
|
||||
"es": "API Key"
|
||||
},
|
||||
"SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL": {
|
||||
"en": "Don't know your API key?",
|
||||
"es": "¿No sabes tu API key?"
|
||||
},
|
||||
"SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL": {
|
||||
"en": "Click here for instructions",
|
||||
"es": "Clic aquí para instrucciones"
|
||||
},
|
||||
"SETTINGS_FORM$AGENT_LABEL": {
|
||||
"en": "Agent",
|
||||
"es": "Agente"
|
||||
},
|
||||
"SETTINGS_FORM$SECURITY_ANALYZER_LABEL": {
|
||||
"en": "Security Analyzer (Optional)",
|
||||
"es": "Analizador de seguridad (opcional)"
|
||||
},
|
||||
"SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL": {
|
||||
"en": "Enable Confirmation Mode",
|
||||
"es": "Habilitar modo de confirmación"
|
||||
},
|
||||
"SETTINGS_FORM$SAVE_LABEL": {
|
||||
"en": "Save",
|
||||
"es": "Guardar"
|
||||
},
|
||||
"SETTINGS_FORM$CLOSE_LABEL": {
|
||||
"en": "Close",
|
||||
"es": "Cerrar"
|
||||
},
|
||||
"SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL": {
|
||||
"en": "Reset to defaults",
|
||||
"es": "Reiniciar valores por defect"
|
||||
},
|
||||
"SETTINGS_FORM$CANCEL_LABEL": {
|
||||
"en": "Cancel",
|
||||
"es": "Cancelar"
|
||||
},
|
||||
"SETTINGS_FORM$END_SESSION_LABEL": {
|
||||
"en": "End Session",
|
||||
"es": "Terminar sesión"
|
||||
},
|
||||
"SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE": {
|
||||
"en": "Changing your settings will clear your workspace and start a new session. Are you sure you want to continue?",
|
||||
"es": "Cambiar tu configuración limpiará tu espacio de trabajo e iniciará una nueva sesión. ¿Estás seguro de continuar?"
|
||||
},
|
||||
"SETTINGS_FORM$ARE_YOU_SURE_LABEL": {
|
||||
"en": "Are you sure?",
|
||||
"es": "¿Estás seguro?"
|
||||
},
|
||||
"SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE": {
|
||||
"en": "All saved information in your AI settings will be deleted, including any API keys.",
|
||||
"es": "Toda la información guardada en tu configuración de IA será eliminada, incluyendo tus API Keys"
|
||||
},
|
||||
"PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL": {
|
||||
"en":"New Project",
|
||||
"es":"Nuevo proyecto"
|
||||
},
|
||||
"PROJECT_MENU_DETAILS$AGO_LABEL": {
|
||||
"en":"ago",
|
||||
"es":"atrás"
|
||||
},
|
||||
"STATUS$ERROR_LLM_AUTHENTICATION": {
|
||||
"en": "Error authenticating with the LLM provider. Please check your API key",
|
||||
"es": "Error autenticando con el proveedor de LLM. Por favor revisa tu API key"
|
||||
},
|
||||
"STATUS$ERROR_RUNTIME_DISCONNECTED": {
|
||||
"en": "There was an error while connecting to the runtime. Please refresh the page."
|
||||
},
|
||||
"AGENT_ERROR$BAD_ACTION": {
|
||||
"en": "Agent tried to execute a malformed action."
|
||||
},
|
||||
"AGENT_ERROR$ACTION_TIMEOUT": {
|
||||
"en": "Action timed out."
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": {
|
||||
"en": "Connect to GitHub",
|
||||
"es": "Conectar a GitHub"
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL": {
|
||||
"en": "Push to GitHub",
|
||||
"es": "Subir a GitHub"
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL": {
|
||||
"en": "Download as .zip",
|
||||
"es": "Descargar como .zip"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 335 B After Width: | Height: | Size: 335 B |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 662 B After Width: | Height: | Size: 662 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 387 B After Width: | Height: | Size: 387 B |