mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
37 Commits
openhands-
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5f482400 | ||
|
|
ac6101833b | ||
|
|
a07df54fe1 | ||
|
|
faa6b91530 | ||
|
|
74f72a4dc9 | ||
|
|
3df3a07a98 | ||
|
|
a53dd1773b | ||
|
|
b9a8b756ae | ||
|
|
ce2a7c4b1f | ||
|
|
b71623510c | ||
|
|
3ad6afbe08 | ||
|
|
f763ba827c | ||
|
|
aac04fe6ca | ||
|
|
8bd4ae2a77 | ||
|
|
ebce3c2fe4 | ||
|
|
037457dec9 | ||
|
|
7f665c2fb6 | ||
|
|
ebb2d86ce3 | ||
|
|
6a4442e590 | ||
|
|
157ff4a4b9 | ||
|
|
cc928e6d3f | ||
|
|
6a75800e1b | ||
|
|
c9cecbc461 | ||
|
|
97b1867ea1 | ||
|
|
9bdc1df2df | ||
|
|
9d984aaa30 | ||
|
|
5ed80b5c32 | ||
|
|
df82202178 | ||
|
|
500598666e | ||
|
|
69a9080480 | ||
|
|
b72f50cc4a | ||
|
|
f1a8be3817 | ||
|
|
b34209c9a0 | ||
|
|
a021045dce | ||
|
|
ad45f8dab0 | ||
|
|
3bf5956493 | ||
|
|
d86b536d2f |
66
.github/scripts/check_version_consistency.py
vendored
Executable file
66
.github/scripts/check_version_consistency.py
vendored
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Set, Tuple
|
||||
|
||||
|
||||
def find_version_references(directory: str) -> Tuple[Set[str], Set[str]]:
|
||||
openhands_versions = set()
|
||||
runtime_versions = set()
|
||||
|
||||
version_pattern_openhands = re.compile(r'openhands:(\d{1})\.(\d{2})')
|
||||
version_pattern_runtime = re.compile(r'runtime:(\d{1})\.(\d{2})')
|
||||
|
||||
for root, _, files in os.walk(directory):
|
||||
# Skip .git directory
|
||||
if '.git' in root:
|
||||
continue
|
||||
|
||||
for file in files:
|
||||
if file.endswith(
|
||||
('.md', '.yml', '.yaml', '.txt', '.html', '.py', '.js', '.ts')
|
||||
):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find all openhands version references
|
||||
matches = version_pattern_openhands.findall(content)
|
||||
openhands_versions.update(matches)
|
||||
|
||||
# Find all runtime version references
|
||||
matches = version_pattern_runtime.findall(content)
|
||||
runtime_versions.update(matches)
|
||||
except Exception as e:
|
||||
print(f'Error reading {file_path}: {e}', file=sys.stderr)
|
||||
|
||||
return openhands_versions, runtime_versions
|
||||
|
||||
|
||||
def main():
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
openhands_versions, runtime_versions = find_version_references(repo_root)
|
||||
|
||||
exit_code = 0
|
||||
|
||||
if len(openhands_versions) > 1:
|
||||
print('Error: Multiple openhands versions found:', file=sys.stderr)
|
||||
print('Found versions:', sorted(openhands_versions), file=sys.stderr)
|
||||
exit_code = 1
|
||||
elif len(openhands_versions) == 0:
|
||||
print('Warning: No openhands version references found', file=sys.stderr)
|
||||
|
||||
if len(runtime_versions) > 1:
|
||||
print('Error: Multiple runtime versions found:', file=sys.stderr)
|
||||
print('Found versions:', sorted(runtime_versions), file=sys.stderr)
|
||||
exit_code = 1
|
||||
elif len(runtime_versions) == 0:
|
||||
print('Warning: No runtime version references found', file=sys.stderr)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
13
.github/workflows/lint.yml
vendored
13
.github/workflows/lint.yml
vendored
@@ -53,3 +53,16 @@ jobs:
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Run pre-commit hooks
|
||||
run: pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
# Check version consistency across documentation
|
||||
check-version-consistency:
|
||||
name: Check version consistency
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Run version consistency check
|
||||
run: .github/scripts/check_version_consistency.py
|
||||
|
||||
@@ -49,7 +49,7 @@ docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/home/openhands/.openhands-state \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
|
||||
@@ -43,7 +43,8 @@ ENV WORKSPACE_BASE=/opt/workspace_base
|
||||
ENV OPENHANDS_BUILD_VERSION=$OPENHANDS_BUILD_VERSION
|
||||
ENV SANDBOX_USER_ID=0
|
||||
ENV FILE_STORE=local
|
||||
ENV FILE_STORE_PATH=~/.openhands-state
|
||||
ENV FILE_STORE_PATH=/.openhands-state
|
||||
RUN mkdir -p $FILE_STORE_PATH
|
||||
RUN mkdir -p $WORKSPACE_BASE
|
||||
|
||||
RUN apt-get update -y \
|
||||
|
||||
@@ -52,7 +52,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.16-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -61,7 +61,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.16 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -46,7 +46,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.16-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -56,6 +56,6 @@ 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.16 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
La façon la plus simple d'exécuter OpenHands est avec Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.16
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17
|
||||
```
|
||||
|
||||
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -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.16-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-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.16 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -47,7 +47,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.16-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -57,6 +57,6 @@ 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.16 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.16
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -17,6 +17,7 @@ docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
|
||||
@@ -176,24 +176,20 @@ Guidelines:
|
||||
|
||||
Examples:
|
||||
1. Creating a Dockerfile:
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
2. Docker Compose usage:
|
||||
```yaml
|
||||
version: '3'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
```
|
||||
|
||||
Remember to:
|
||||
- Validate Dockerfile syntax
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import Layout from "@theme/Layout";
|
||||
import { HomepageHeader } from "../components/HomepageHeader/HomepageHeader";
|
||||
import { Welcome } from "../components/Welcome/Welcome";
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import Layout from '@theme/Layout';
|
||||
import { HomepageHeader } from '../components/HomepageHeader/HomepageHeader';
|
||||
import { translate } from '@docusaurus/Translate';
|
||||
|
||||
export function Header({ title, summary }): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<h2 style={{ fontSize: "3rem" }}>{summary}</h2>
|
||||
<h2 style={{ fontSize: '3rem' }}>{summary}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +22,7 @@ export default function Home(): JSX.Element {
|
||||
message: 'Code Less, Make More',
|
||||
})}
|
||||
>
|
||||
<HomepageHeader />
|
||||
<HomepageHeader />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ This folder contains the evaluation harness that we built on top of the original
|
||||
The evaluation consists of three steps:
|
||||
|
||||
1. Environment setup: [install python environment](../../README.md#development-environment), [configure LLM config](../../README.md#configure-openhands-and-your-llm), [launch services](https://github.com/TheAgentCompany/TheAgentCompany/blob/main/docs/SETUP.md).
|
||||
2. [Run Evaluation](#run-inference-on-the-agent-company-instances): Run all tasks and get the evaluation results.
|
||||
2. [Run Evaluation](#run-inference-on-the-agent-company-tasks): Run all tasks and get the evaluation results.
|
||||
|
||||
## Setup Environment and LLM Configuration
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
###########################################################################################################
|
||||
# Adapted from https://github.com/TheAgentCompany/TheAgentCompany/blob/main/evaluation/summarise_results.py
|
||||
###########################################################################################################
|
||||
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Dict, Tuple
|
||||
|
||||
|
||||
def calculate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
||||
"""
|
||||
Calculate the cost of the model call.
|
||||
"""
|
||||
if 'claude-3-5-sonnet' in model.lower():
|
||||
# https://www.anthropic.com/pricing#anthropic-api, accessed 12/11/2024
|
||||
return 0.000003 * prompt_tokens + 0.000015 * completion_tokens
|
||||
elif 'gpt-4o' in model.lower():
|
||||
# https://openai.com/api/pricing/, accessed 12/11/2024
|
||||
return 0.0000025 * prompt_tokens + 0.00001 * completion_tokens
|
||||
elif 'gemini-1.5-pro' in model.lower():
|
||||
# https://ai.google.dev/pricing#1_5pro, accessed 12/11/2024
|
||||
# assuming prompts up to 128k tokens
|
||||
cost = 0.00000125 * prompt_tokens + 0.000005 * completion_tokens
|
||||
if prompt_tokens > 128000:
|
||||
cost *= 2
|
||||
return cost
|
||||
elif 'gemini-2.0-flash-exp' in model.lower():
|
||||
# price unknown for gemini-2.0-flash-exp, assuming same price as gemini-1.5-flash
|
||||
cost = 0.000000075 * prompt_tokens + 0.0000003 * completion_tokens
|
||||
if prompt_tokens > 128000:
|
||||
cost *= 2
|
||||
return cost
|
||||
elif 'qwen2-72b' in model.lower():
|
||||
# assuming hosted on Together
|
||||
# https://www.together.ai/pricing, accessed 12/11/2024
|
||||
return 0.0000009 * (prompt_tokens + completion_tokens)
|
||||
elif 'qwen2p5-72b' in model.lower():
|
||||
# assuming hosted on Together
|
||||
# https://www.together.ai/pricing, accessed 12/14/2024
|
||||
return 0.0000012 * (prompt_tokens + completion_tokens)
|
||||
elif 'llama-v3p1-405b-instruct' in model.lower():
|
||||
# assuming hosted on Fireworks AI
|
||||
# https://fireworks.ai/pricing, accessed 12/11/2024
|
||||
return 0.000003 * (prompt_tokens + completion_tokens)
|
||||
elif 'llama-v3p1-70b-instruct' in model.lower():
|
||||
# assuming hosted on Fireworks AI
|
||||
return 0.0000009 * (prompt_tokens + completion_tokens)
|
||||
elif 'llama-v3p3-70b-instruct' in model.lower():
|
||||
# assuming hosted on Fireworks AI
|
||||
return 0.0000009 * (prompt_tokens + completion_tokens)
|
||||
elif 'amazon.nova-pro-v1:0' in model.lower():
|
||||
# assuming hosted on Amazon Bedrock
|
||||
# https://aws.amazon.com/bedrock/pricing/, accessed 12/11/2024
|
||||
return 0.0000008 * prompt_tokens + 0.0000032 * completion_tokens
|
||||
else:
|
||||
raise ValueError(f'Unknown model: {model}')
|
||||
|
||||
|
||||
def analyze_eval_json_file(filepath: str) -> Tuple[int, int]:
|
||||
"""
|
||||
Analyze a single eval JSON file and extract the total and result from final_score.
|
||||
|
||||
Args:
|
||||
filepath: Path to the JSON file
|
||||
|
||||
Returns:
|
||||
Tuple containing (total, result) from final_score
|
||||
"""
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
final_score = data.get('final_score', {})
|
||||
return (final_score.get('total', 0), final_score.get('result', 0))
|
||||
except json.JSONDecodeError as e:
|
||||
print(f'Error decoding JSON in {filepath}: {e}')
|
||||
return (0, 0)
|
||||
except Exception as e:
|
||||
print(f'Error processing {filepath}: {e}')
|
||||
return (0, 0)
|
||||
|
||||
|
||||
def analyze_traj_json_file(filepath: str) -> Tuple[int, float]:
|
||||
"""
|
||||
Analyze a single trajectory JSON file and extract the steps and tokens
|
||||
for each step. Then estimate the cost based on the tokens and the model type.
|
||||
Note: this is assuming there's no prompt caching at all.
|
||||
"""
|
||||
steps: int = 0
|
||||
cost: float = 0.0
|
||||
with open(filepath, 'r') as f:
|
||||
data = json.load(f)
|
||||
response_id = None
|
||||
for action in data:
|
||||
if 'tool_call_metadata' in action:
|
||||
if action['tool_call_metadata']['model_response']['id'] != response_id:
|
||||
response_id = action['tool_call_metadata']['model_response']['id']
|
||||
else:
|
||||
# openhands displays the same model response meta data multiple times, when
|
||||
# a single LLM call leads to multiple actions and observations.
|
||||
continue
|
||||
steps += 1
|
||||
usage = action['tool_call_metadata']['model_response']['usage']
|
||||
model: str = action['tool_call_metadata']['model_response']['model']
|
||||
prompt_tokens = usage['prompt_tokens']
|
||||
completion_tokens = usage['completion_tokens']
|
||||
cost += calculate_cost(model, prompt_tokens, completion_tokens)
|
||||
|
||||
return (steps, cost)
|
||||
|
||||
|
||||
def analyze_folder(
|
||||
folder_path: str,
|
||||
) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, Tuple[int, float]]]:
|
||||
"""
|
||||
Analyze all eval_*.json & traj_*.json files in the specified folder.
|
||||
|
||||
Args:
|
||||
folder_path: Path to the folder containing JSON files
|
||||
|
||||
Returns:
|
||||
dictionaries:
|
||||
- eval_results: Dictionary with filename as key and (total, result) tuple as value
|
||||
- traj_results: Dictionary with filename as key and (steps, cost) tuple as value
|
||||
"""
|
||||
eval_results = {}
|
||||
traj_results = {}
|
||||
|
||||
eval_pattern = os.path.join(folder_path, 'eval_*.json')
|
||||
traj_pattern = os.path.join(folder_path, 'traj_*.json')
|
||||
|
||||
for filepath in glob.glob(eval_pattern):
|
||||
filename = os.path.basename(filepath)
|
||||
total, result = analyze_eval_json_file(filepath)
|
||||
key = re.search(r'eval_(.+)\.json', filename).group(1)
|
||||
eval_results[key] = (total, result)
|
||||
|
||||
for filepath in glob.glob(traj_pattern):
|
||||
filename = os.path.basename(filepath)
|
||||
steps, cost = analyze_traj_json_file(filepath)
|
||||
key = re.search(r'traj_(.+)\.json', filename).group(1)
|
||||
traj_results[key] = (steps, cost)
|
||||
|
||||
return eval_results, traj_results
|
||||
|
||||
|
||||
def get_task_nature_category(task_name: str) -> str:
|
||||
"""
|
||||
Get the nature category of the task.
|
||||
"""
|
||||
task_nature = task_name.split('-')[0]
|
||||
if task_nature.lower() in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance']:
|
||||
return task_nature
|
||||
else:
|
||||
return 'other'
|
||||
|
||||
|
||||
def calculate_score(total: int, result: int) -> float:
|
||||
"""
|
||||
Calculate the score as a number between 0 and 1.
|
||||
|
||||
Formula: score = (result / total) * 0.5 + (result // total) * 0.5
|
||||
Explanation:
|
||||
- (result / total) * 0.5: This is the completion ratio, scaled down to a 0-0.5 range.
|
||||
- (result // total) * 0.5: This is a binary score indicating whether the task was completed or not.
|
||||
|
||||
Args:
|
||||
total: Total possible points
|
||||
result: Actual points achieved
|
||||
|
||||
Returns:
|
||||
Score as a number between 0 and 1
|
||||
"""
|
||||
return (result / total * 0.5) + (result // total * 0.5)
|
||||
|
||||
|
||||
def is_perfect_completion(total: int, result: int) -> bool:
|
||||
"""
|
||||
Check if the task achieved perfect completion.
|
||||
|
||||
Args:
|
||||
total: Total possible points
|
||||
result: Actual points achieved
|
||||
|
||||
Returns:
|
||||
True if result equals total, False otherwise
|
||||
"""
|
||||
return total > 0 and total == result
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print('Usage: poetry run python summarise_results.py <folder_path>')
|
||||
sys.exit(1)
|
||||
|
||||
folder_path = sys.argv[1]
|
||||
|
||||
if not os.path.isdir(folder_path):
|
||||
print(f"Error: '{folder_path}' is not a valid directory")
|
||||
sys.exit(1)
|
||||
|
||||
eval_results, traj_results = analyze_folder(folder_path)
|
||||
|
||||
if not eval_results:
|
||||
print(f'No eval_*.json files found in {folder_path}')
|
||||
return
|
||||
|
||||
# Create list of results with completion ratios for sorting
|
||||
detailed_results = [
|
||||
(
|
||||
task_name,
|
||||
total,
|
||||
result,
|
||||
calculate_score(total, result),
|
||||
is_perfect_completion(total, result),
|
||||
get_task_nature_category(task_name),
|
||||
)
|
||||
for task_name, (total, result) in eval_results.items()
|
||||
]
|
||||
|
||||
# Sort by score in descending order
|
||||
detailed_results.sort(key=lambda x: (-x[3], x[0]))
|
||||
|
||||
# Calculate perfect completion stats
|
||||
perfect_completions = sum(
|
||||
1 for _, _, _, _, is_perfect, _ in detailed_results if is_perfect
|
||||
)
|
||||
|
||||
# Print header
|
||||
print('\n# Evaluation Results Report')
|
||||
print('\n## Results per File')
|
||||
print('\n*Sorted by score (⭐ indicates perfect completion)*\n')
|
||||
|
||||
# Print table header
|
||||
print(
|
||||
'| Filename | Total | Result | Score | Steps | Cost (assuming no prompt caching)|'
|
||||
)
|
||||
print('|----------|--------|---------|-------|-------|------|')
|
||||
|
||||
# Print individual file results
|
||||
for task_name, total, result, score, is_perfect, task_nature in detailed_results:
|
||||
perfect_marker = ' ⭐' if is_perfect else ''
|
||||
print(
|
||||
f'| {task_name} | {total:,} | {result:,} | {score:.2f}{perfect_marker} | {traj_results[task_name][0]} | {traj_results[task_name][1]:.2f} |'
|
||||
)
|
||||
|
||||
# Print summary section
|
||||
print('\n## Summary\n')
|
||||
print(f'**Tasks Evaluated:** {len(eval_results)}\n')
|
||||
print(
|
||||
f'**Perfect Completions:** {perfect_completions}/{len(eval_results)} ({(perfect_completions/len(eval_results)*100):.2f}%)\n'
|
||||
)
|
||||
|
||||
overall_score = (
|
||||
sum(score for _, _, _, score, _, _ in detailed_results)
|
||||
/ len(detailed_results)
|
||||
* 100
|
||||
)
|
||||
avg_steps = sum(steps for steps, _ in traj_results.values()) / len(traj_results)
|
||||
avg_cost = sum(cost for _, cost in traj_results.values()) / len(traj_results)
|
||||
print(f'**Overall Score:** {overall_score:.2f}%\n')
|
||||
print(f'**Average Steps:** {avg_steps:.2f}\n')
|
||||
print(f'**Average Cost (USD):** {avg_cost:.2f}\n')
|
||||
|
||||
# Additional statistics
|
||||
if detailed_results:
|
||||
highest_score = max(score for _, _, _, score, _, _ in detailed_results)
|
||||
lowest_score = min(score for _, _, _, score, _, _ in detailed_results)
|
||||
median_score = detailed_results[len(detailed_results) // 2][3]
|
||||
avg_score = sum(score for _, _, _, score, _, _ in detailed_results) / len(
|
||||
detailed_results
|
||||
)
|
||||
|
||||
print('\n## Statistics\n')
|
||||
print('| Metric | Value |')
|
||||
print('|---------|--------|')
|
||||
print(f'| Highest Task Score | {highest_score*100:.2f}% |')
|
||||
print(f'| Lowest Task Score | {lowest_score*100:.2f}% |')
|
||||
print(f'| Median Task Score | {median_score*100:.2f}% |')
|
||||
print(f'| Average Task Score | {avg_score*100:.2f}% |')
|
||||
|
||||
# compute avg score per nature category
|
||||
print('\n## Statistics per Nature Category\n')
|
||||
print('| Metric | Value |')
|
||||
print('|---------|--------|')
|
||||
for task_nature in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance', 'other']:
|
||||
num_of_tasks = sum(
|
||||
1
|
||||
for _, _, _, _, _, nature_category in detailed_results
|
||||
if nature_category == task_nature
|
||||
)
|
||||
task_nature_score = (
|
||||
sum(
|
||||
score
|
||||
for _, _, _, score, _, nature_category in detailed_results
|
||||
if nature_category == task_nature
|
||||
)
|
||||
/ num_of_tasks
|
||||
)
|
||||
perfect_completions = sum(
|
||||
1
|
||||
for _, _, _, _, is_perfect, nature_category in detailed_results
|
||||
if nature_category == task_nature and is_perfect
|
||||
)
|
||||
print(
|
||||
f'| Perfect Completions for {task_nature} | {perfect_completions}/{num_of_tasks} ({perfect_completions/num_of_tasks*100:.2f}%) |'
|
||||
)
|
||||
print(f'| Average Score for {task_nature} | {task_nature_score*100:.2f}% |')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
35
frontend/__tests__/components/workspace-toggle.test.tsx
Normal file
35
frontend/__tests__/components/workspace-toggle.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
|
||||
|
||||
describe("Workspace Toggle", () => {
|
||||
it("should render toggle button with correct icon and label", () => {
|
||||
const onClickMock = vi.fn();
|
||||
|
||||
// Test initial state (workspace visible)
|
||||
const { rerender } = render(
|
||||
<ToggleWorkspaceIconButton onClick={onClickMock} isHidden={false} />
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("toggle");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute("aria-label", "Close workspace");
|
||||
|
||||
// Test hidden state
|
||||
rerender(
|
||||
<ToggleWorkspaceIconButton onClick={onClickMock} isHidden={true} />
|
||||
);
|
||||
expect(button).toHaveAttribute("aria-label", "Open workspace");
|
||||
});
|
||||
|
||||
it("should call onClick handler when clicked", () => {
|
||||
const onClickMock = vi.fn();
|
||||
render(
|
||||
<ToggleWorkspaceIconButton onClick={onClickMock} isHidden={false} />
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("toggle");
|
||||
fireEvent.click(button);
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ describe("frontend/routes/_oh", () => {
|
||||
await screen.findByTestId("root-layout");
|
||||
});
|
||||
|
||||
it("should render the AI config modal if settings are not up-to-date", async () => {
|
||||
it.skip("should render the AI config modal if settings are not up-to-date", async () => {
|
||||
settingsAreUpToDateMock.mockReturnValue(false);
|
||||
renderWithProviders(<RouteStub />);
|
||||
|
||||
|
||||
198
frontend/package-lock.json
generated
198
frontend/package-lock.json
generated
@@ -8,56 +8,56 @@
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.17.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
"@react-router/node": "^7.0.1",
|
||||
"@react-router/serve": "^7.0.1",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@nextui-org/react": "^2.6.10",
|
||||
"@react-router/node": "^7.1.1",
|
||||
"@react-router/serve": "^7.1.1",
|
||||
"@react-types/shared": "^3.25.0",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@tanstack/react-query": "^5.62.10",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"i18next": "^23.15.2",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-http-backend": "^2.6.2",
|
||||
"isbot": "^5.1.17",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"isbot": "^5.1.19",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"posthog-js": "^1.184.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.203.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-i18next": "^15.0.2",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router": "^7.0.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.4",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vite": "^5.4.9",
|
||||
"web-vitals": "^3.5.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.2",
|
||||
"@react-router/dev": "^7.0.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.9",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.7.6",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.5.12",
|
||||
@@ -73,18 +73,18 @@
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"lint-staged": "^15.2.10",
|
||||
"lint-staged": "^15.2.11",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1595,17 +1595,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
|
||||
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
|
||||
"version": "4.7.0-rc.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0-rc.0.tgz",
|
||||
"integrity": "sha512-YfjXkDK0bcwS0zo8PXptvQdCQfOPPtzGsAzmIv7PnoUGFdIohsR+NVDyjbajMddF+3cWUm/3q9NzP/DUke9a+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mswjs/interceptors": {
|
||||
@@ -2241,6 +2241,23 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nextui-org/listbox/node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
|
||||
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.10.9"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nextui-org/menu": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nextui-org/menu/-/menu-2.2.8.tgz",
|
||||
@@ -2579,6 +2596,23 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nextui-org/select/node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
|
||||
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.10.9"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nextui-org/shared-icons": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nextui-org/shared-icons/-/shared-icons-2.1.1.tgz",
|
||||
@@ -5336,23 +5370,6 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
|
||||
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.10.9"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz",
|
||||
@@ -5603,30 +5620,23 @@
|
||||
"undici-types": "~6.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
||||
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
|
||||
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
|
||||
"version": "19.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.2.tgz",
|
||||
"integrity": "sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
|
||||
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
|
||||
"version": "19.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz",
|
||||
"integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-highlight": {
|
||||
@@ -10141,9 +10151,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "23.16.8",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
|
||||
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
|
||||
"version": "24.2.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",
|
||||
"integrity": "sha512-ArJJTS1lV6lgKH7yEf4EpgNZ7+THl7bsGxxougPYiXRTJ/Fe1j08/TBpV9QsXCIYVfdE/HWG/xLezJ5DOlfBOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -10161,6 +10171,14 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
@@ -10173,9 +10191,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.7.1.tgz",
|
||||
"integrity": "sha512-vPksHIckysGgykCD8JwCr2YsJEml9Cyw+Yu2wtb4fQ7xIn9RH/hkUDh5UkwnIzb0kSL4SJ30Ab/sCInhQxbCgg==",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz",
|
||||
"integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
@@ -11536,6 +11554,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -14059,28 +14078,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
||||
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.25.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-highlight": {
|
||||
@@ -14918,13 +14933,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scroll-into-view-if-needed": {
|
||||
"version": "3.0.10",
|
||||
|
||||
@@ -7,41 +7,41 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
"@react-router/node": "^7.0.1",
|
||||
"@react-router/serve": "^7.0.1",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@nextui-org/react": "^2.6.10",
|
||||
"@react-router/node": "^7.1.1",
|
||||
"@react-router/serve": "^7.1.1",
|
||||
"@react-types/shared": "^3.25.0",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@tanstack/react-query": "^5.62.10",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"i18next": "^23.15.2",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-http-backend": "^2.6.2",
|
||||
"isbot": "^5.1.17",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"isbot": "^5.1.19",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"posthog-js": "^1.184.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.203.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-i18next": "^15.0.2",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router": "^7.0.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.4",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vite": "^5.4.9",
|
||||
"web-vitals": "^3.5.2",
|
||||
"ws": "^8.18.0"
|
||||
@@ -75,16 +75,16 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.2",
|
||||
"@react-router/dev": "^7.0.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.9",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.7.6",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.5.12",
|
||||
@@ -100,18 +100,18 @@
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"lint-staged": "^15.2.10",
|
||||
"lint-staged": "^15.2.11",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"packageManager": "npm@10.5.0",
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.6.6'
|
||||
const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074'
|
||||
const PACKAGE_VERSION = '2.7.0'
|
||||
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
@@ -199,7 +199,19 @@ async function getResponse(event, client, requestId) {
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
headers.delete('accept', 'msw/passthrough')
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
@@ -250,14 +250,12 @@ class OpenHands {
|
||||
|
||||
static async newConversation(params: {
|
||||
githubToken?: string;
|
||||
args?: Record<string, unknown>;
|
||||
selectedRepository?: string;
|
||||
}): Promise<{ conversation_id: string }> {
|
||||
const { data } = await openHands.post<{
|
||||
conversation_id: string;
|
||||
}>("/api/conversations", {
|
||||
github_token: params.githubToken,
|
||||
args: params.args,
|
||||
selected_repository: params.selectedRepository,
|
||||
});
|
||||
// TODO: remove this once we have a multi-conversation UI
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
function ArrowIcon(): JSX.Element {
|
||||
function ArrowIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
function CogTooth(): JSX.Element {
|
||||
function CogTooth() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
function ConfirmIcon(): JSX.Element {
|
||||
function ConfirmIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
function PauseIcon(): JSX.Element {
|
||||
function PauseIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
function PlayIcon(): JSX.Element {
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
function RejectIcon(): JSX.Element {
|
||||
function RejectIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
function StopIcon(): JSX.Element {
|
||||
function StopIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FaPython,
|
||||
} from "react-icons/fa";
|
||||
|
||||
export const EXTENSION_ICON_MAP: Record<string, JSX.Element> = {
|
||||
export const EXTENSION_ICON_MAP: Record<string, React.ReactNode> = {
|
||||
js: <DiJavascript />,
|
||||
ts: <DiJavascript />,
|
||||
py: <FaPython />,
|
||||
|
||||
@@ -2,13 +2,19 @@ import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuProps {
|
||||
ref: React.RefObject<HTMLUListElement | null>;
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
className?: React.HTMLAttributes<HTMLUListElement>["className"];
|
||||
}
|
||||
|
||||
export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
|
||||
({ testId, children, className }, ref) => (
|
||||
export function ContextMenu({
|
||||
testId,
|
||||
children,
|
||||
className,
|
||||
ref,
|
||||
}: ContextMenuProps) {
|
||||
return (
|
||||
<ul
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
@@ -16,7 +22,5 @@ export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
);
|
||||
|
||||
ContextMenu.displayName = "ContextMenu";
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Editor, EditorProps } from "@monaco-editor/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VscCode } from "react-icons/vsc";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useFiles } from "#/context/files";
|
||||
import { useSaveFile } from "#/hooks/mutation/use-save-file";
|
||||
|
||||
interface CodeEditorComponentProps {
|
||||
onMount: EditorProps["onMount"];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
function CodeEditorComponent({
|
||||
onMount,
|
||||
isReadOnly,
|
||||
}: CodeEditorComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
files,
|
||||
selectedPath,
|
||||
modifiedFiles,
|
||||
modifyFileContent,
|
||||
saveFileContent: saveNewFileContent,
|
||||
} = useFiles();
|
||||
|
||||
const { mutate: saveFile } = useSaveFile();
|
||||
|
||||
const handleEditorChange = (value: string | undefined) => {
|
||||
if (selectedPath && value) modifyFileContent(selectedPath, value);
|
||||
};
|
||||
|
||||
const isBase64Image = (content: string) => content.startsWith("data:image/");
|
||||
const isPDF = (content: string) => content.startsWith("data:application/pdf");
|
||||
const isVideo = (content: string) => content.startsWith("data:video/");
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleSave = async (event: KeyboardEvent) => {
|
||||
if (selectedPath && event.metaKey && event.key === "s") {
|
||||
const content = saveNewFileContent(selectedPath);
|
||||
|
||||
if (content) {
|
||||
saveFile({ path: selectedPath, content });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleSave);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleSave);
|
||||
};
|
||||
}, [saveNewFileContent]);
|
||||
|
||||
if (!selectedPath) {
|
||||
return (
|
||||
<div
|
||||
data-testid="code-editor-empty-message"
|
||||
className="flex flex-col h-full items-center justify-center text-neutral-400"
|
||||
>
|
||||
<VscCode size={100} />
|
||||
{t(I18nKey.CODE_EDITOR$EMPTY_MESSAGE)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileContent: string | undefined =
|
||||
modifiedFiles[selectedPath] || files[selectedPath];
|
||||
|
||||
if (fileContent) {
|
||||
if (isBase64Image(fileContent)) {
|
||||
return (
|
||||
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
|
||||
<img
|
||||
src={fileContent}
|
||||
alt={selectedPath}
|
||||
className="object-contain"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPDF(fileContent)) {
|
||||
return (
|
||||
<iframe
|
||||
src={fileContent}
|
||||
title={selectedPath}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideo(fileContent)) {
|
||||
return (
|
||||
<video controls src={fileContent} width="100%" height="100%">
|
||||
<track kind="captions" label="English captions" />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Editor
|
||||
data-testid="code-editor"
|
||||
path={selectedPath ?? undefined}
|
||||
defaultValue=""
|
||||
value={selectedPath ? fileContent : undefined}
|
||||
onMount={onMount}
|
||||
onChange={handleEditorChange}
|
||||
options={{ readOnly: isReadOnly }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CodeEditorComponent);
|
||||
@@ -1,33 +0,0 @@
|
||||
import { EditorActionButton } from "#/components/shared/buttons/editor-action-button";
|
||||
|
||||
interface EditorActionsProps {
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export function EditorActions({
|
||||
onSave,
|
||||
onDiscard,
|
||||
isDisabled,
|
||||
}: EditorActionsProps) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<EditorActionButton
|
||||
onClick={onSave}
|
||||
disabled={isDisabled}
|
||||
className="bg-neutral-800 disabled:hover:bg-neutral-800"
|
||||
>
|
||||
Save
|
||||
</EditorActionButton>
|
||||
|
||||
<EditorActionButton
|
||||
onClick={onDiscard}
|
||||
disabled={isDisabled}
|
||||
className="border border-neutral-800 disabled:hover:bg-transparent"
|
||||
>
|
||||
Discard
|
||||
</EditorActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IoFileTray } from "react-icons/io5";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface DropzoneProps {
|
||||
onDragLeave: () => void;
|
||||
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function Dropzone({ onDragLeave, onDrop }: DropzoneProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="dropzone"
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
className="z-10 absolute flex flex-col justify-center items-center bg-black top-0 bottom-0 left-0 right-0 opacity-65"
|
||||
>
|
||||
<IoFileTray size={32} />
|
||||
<p className="font-bold text-xl">
|
||||
{t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { RefreshIconButton } from "#/components/shared/buttons/refresh-icon-button";
|
||||
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
|
||||
import { UploadIconButton } from "#/components/shared/buttons/upload-icon-button";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ExplorerActionsProps {
|
||||
onRefresh: () => void;
|
||||
onUpload: () => void;
|
||||
toggleHidden: () => void;
|
||||
isHidden: boolean;
|
||||
}
|
||||
@@ -13,7 +11,6 @@ interface ExplorerActionsProps {
|
||||
export function ExplorerActions({
|
||||
toggleHidden,
|
||||
onRefresh,
|
||||
onUpload,
|
||||
isHidden,
|
||||
}: ExplorerActionsProps) {
|
||||
return (
|
||||
@@ -23,12 +20,7 @@ export function ExplorerActions({
|
||||
isHidden ? "right-3" : "right-2",
|
||||
)}
|
||||
>
|
||||
{!isHidden && (
|
||||
<>
|
||||
<RefreshIconButton onClick={onRefresh} />
|
||||
<UploadIconButton onClick={onUpload} />
|
||||
</>
|
||||
)}
|
||||
{!isHidden && <RefreshIconButton onClick={onRefresh} />}
|
||||
|
||||
<ToggleWorkspaceIconButton isHidden={isHidden} onClick={toggleHidden} />
|
||||
</div>
|
||||
|
||||
@@ -7,14 +7,12 @@ interface FileExplorerHeaderProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onRefreshWorkspace: () => void;
|
||||
onUploadFile: () => void;
|
||||
}
|
||||
|
||||
export function FileExplorerHeader({
|
||||
isOpen,
|
||||
onToggle,
|
||||
onRefreshWorkspace,
|
||||
onUploadFile,
|
||||
}: FileExplorerHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -35,7 +33,6 @@ export function FileExplorerHeader({
|
||||
isHidden={!isOpen}
|
||||
toggleHidden={onToggle}
|
||||
onRefresh={onRefreshWorkspace}
|
||||
onUpload={onUploadFile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
|
||||
@@ -7,14 +7,10 @@ import toast from "#/utils/toast";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useListFiles } from "#/hooks/query/use-list-files";
|
||||
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Dropzone } from "./dropzone";
|
||||
import { FileExplorerHeader } from "./file-explorer-header";
|
||||
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
|
||||
import { OpenVSCodeButton } from "#/components/shared/buttons/open-vscode-button";
|
||||
import { addAssistantMessage } from "#/state/chat-slice";
|
||||
|
||||
interface FileExplorerProps {
|
||||
isOpen: boolean;
|
||||
@@ -23,26 +19,16 @@ interface FileExplorerProps {
|
||||
|
||||
export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const { data: paths, refetch, error } = useListFiles();
|
||||
const { mutate: uploadFiles } = useUploadFiles();
|
||||
const { data: vscodeUrl } = useVSCodeUrl({
|
||||
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
|
||||
});
|
||||
|
||||
const handleOpenVSCode = () => {
|
||||
if (vscodeUrl?.vscode_url) {
|
||||
dispatch(
|
||||
addAssistantMessage(
|
||||
"You opened VS Code. Please inform the agent of any changes you made to the workspace or environment. To avoid conflicts, it's best to pause the agent before making any changes.",
|
||||
),
|
||||
);
|
||||
window.open(vscodeUrl.vscode_url, "_blank");
|
||||
} else if (vscodeUrl?.error) {
|
||||
toast.error(
|
||||
@@ -54,86 +40,18 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const selectFileInput = () => {
|
||||
fileInputRef.current?.click(); // Trigger the file browser
|
||||
};
|
||||
|
||||
const handleUploadSuccess = (data: FileUploadSuccessResponse) => {
|
||||
const uploadedCount = data.uploaded_files.length;
|
||||
const skippedCount = data.skipped_files.length;
|
||||
|
||||
if (uploadedCount > 0) {
|
||||
toast.success(
|
||||
`upload-success-${new Date().getTime()}`,
|
||||
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
|
||||
count: uploadedCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
|
||||
count: skippedCount,
|
||||
});
|
||||
toast.info(message);
|
||||
}
|
||||
|
||||
if (uploadedCount === 0 && skippedCount === 0) {
|
||||
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadError = (uploadError: Error) => {
|
||||
toast.error(
|
||||
`upload-error-${new Date().getTime()}`,
|
||||
uploadError.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
|
||||
);
|
||||
};
|
||||
|
||||
const refreshWorkspace = () => {
|
||||
if (!RUNTIME_INACTIVE_STATES.includes(curAgentState)) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFileData = (files: FileList) => {
|
||||
uploadFiles(
|
||||
{ files: Array.from(files) },
|
||||
{ onSuccess: handleUploadSuccess, onError: handleUploadError },
|
||||
);
|
||||
refreshWorkspace();
|
||||
};
|
||||
|
||||
const handleDropFiles = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const { files: droppedFiles } = event.dataTransfer;
|
||||
if (droppedFiles.length > 0) {
|
||||
uploadFileData(droppedFiles);
|
||||
}
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
refreshWorkspace();
|
||||
}, [curAgentState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="file-explorer"
|
||||
className="relative h-full"
|
||||
onDragEnter={() => {
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setIsDragging(false);
|
||||
}}
|
||||
>
|
||||
{isDragging && (
|
||||
<Dropzone
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDropFiles}
|
||||
/>
|
||||
)}
|
||||
<div data-testid="file-explorer" className="relative h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
|
||||
@@ -145,7 +63,6 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
onRefreshWorkspace={refreshWorkspace}
|
||||
onUploadFile={selectFileInput}
|
||||
/>
|
||||
{!error && (
|
||||
<div className="overflow-auto flex-grow min-h-0">
|
||||
|
||||
@@ -4,7 +4,7 @@ interface FolderIconProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function FolderIcon({ isOpen }: FolderIconProps): JSX.Element {
|
||||
export function FolderIcon({ isOpen }: FolderIconProps) {
|
||||
return isOpen ? (
|
||||
<FaFolderOpen color="D9D3D0" className="icon" />
|
||||
) : (
|
||||
|
||||
@@ -14,13 +14,7 @@ interface TreeNodeProps {
|
||||
}
|
||||
|
||||
function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
|
||||
const {
|
||||
setFileContent,
|
||||
modifiedFiles,
|
||||
setSelectedPath,
|
||||
files,
|
||||
selectedPath,
|
||||
} = useFiles();
|
||||
const { setFileContent, setSelectedPath, files, selectedPath } = useFiles();
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
@@ -35,8 +29,7 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (fileContent) {
|
||||
const code = modifiedFiles[path] || files[path];
|
||||
if (!code || fileContent !== files[path]) {
|
||||
if (fileContent !== files[path]) {
|
||||
setFileContent(path, fileContent);
|
||||
}
|
||||
}
|
||||
@@ -79,10 +72,6 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
|
||||
type={isDirectory ? "folder" : "file"}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
{modifiedFiles[path] && (
|
||||
<div className="w-2 h-2 rounded-full bg-neutral-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && paths && (
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ToggleWorkspaceIconButton } from "../toggle-workspace-icon-button";
|
||||
|
||||
describe("ToggleWorkspaceIconButton", () => {
|
||||
it("renders with correct dimensions and styling", () => {
|
||||
const mockOnClick = vi.fn();
|
||||
render(
|
||||
<ToggleWorkspaceIconButton onClick={mockOnClick} isHidden={false} />,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("toggle");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass("h-[100px] w-[20px]");
|
||||
expect(button).toHaveClass("bg-neutral-800");
|
||||
expect(button).toHaveClass("hover:bg-neutral-700");
|
||||
expect(button).toHaveClass("rounded-md");
|
||||
});
|
||||
|
||||
it("displays the correct icon based on isHidden prop", () => {
|
||||
const mockOnClick = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ToggleWorkspaceIconButton onClick={mockOnClick} isHidden={false} />,
|
||||
);
|
||||
expect(screen.getByLabelText("Close workspace")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("toggle")).toContainElement(
|
||||
screen.getByTestId("arrow-forward-icon"),
|
||||
);
|
||||
|
||||
rerender(<ToggleWorkspaceIconButton onClick={mockOnClick} isHidden />);
|
||||
expect(screen.getByLabelText("Open workspace")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("toggle")).toContainElement(
|
||||
screen.getByTestId("arrow-back-icon"),
|
||||
);
|
||||
});
|
||||
|
||||
it("remains visible when workspace is collapsed", () => {
|
||||
const mockOnClick = vi.fn();
|
||||
render(<ToggleWorkspaceIconButton onClick={mockOnClick} isHidden />);
|
||||
|
||||
const button = screen.getByTestId("toggle");
|
||||
expect(button).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Button } from "@nextui-org/react";
|
||||
import React, { MouseEventHandler, ReactElement } from "react";
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
export interface IconButtonProps {
|
||||
icon: ReactElement;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onClick: () => void;
|
||||
ariaLabel: string;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
@@ -13,13 +14,14 @@ export function IconButton({
|
||||
onClick,
|
||||
ariaLabel,
|
||||
testId = "",
|
||||
className = "",
|
||||
}: IconButtonProps): React.ReactElement {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="flat"
|
||||
onClick={onClick}
|
||||
className="cursor-pointer text-[12px] bg-transparent aspect-square px-0 min-w-[20px] h-[20px]"
|
||||
onPress={onClick}
|
||||
className={`cursor-pointer text-[12px] bg-transparent aspect-square px-0 min-w-[12px] h-[20px] ${className}`}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
>
|
||||
|
||||
@@ -14,20 +14,24 @@ export function ToggleWorkspaceIconButton({
|
||||
<IconButton
|
||||
icon={
|
||||
isHidden ? (
|
||||
<IoIosArrowForward
|
||||
size={20}
|
||||
<IoIosArrowBack
|
||||
size={10}
|
||||
className="text-neutral-400 hover:text-neutral-100 transition"
|
||||
data-testid="arrow-back-icon"
|
||||
/>
|
||||
) : (
|
||||
<IoIosArrowBack
|
||||
size={20}
|
||||
<IoIosArrowForward
|
||||
size={10}
|
||||
className="text-neutral-400 hover:text-neutral-100 transition"
|
||||
data-testid="arrow-forward-icon"
|
||||
/>
|
||||
)
|
||||
}
|
||||
testId="toggle"
|
||||
ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
|
||||
title={isHidden ? "Open workspace" : "Close workspace"}
|
||||
onClick={onClick}
|
||||
className="h-[80px] w-[8px] bg-neutral-800 hover:bg-neutral-700 rounded-md"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { IoIosCloudUpload } from "react-icons/io";
|
||||
import { IconButton } from "./icon-button";
|
||||
|
||||
interface UploadIconButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function UploadIconButton({ onClick }: UploadIconButtonProps) {
|
||||
return (
|
||||
<IconButton
|
||||
icon={
|
||||
<IoIosCloudUpload
|
||||
size={16}
|
||||
className="text-neutral-400 hover:text-neutral-100 transition"
|
||||
/>
|
||||
}
|
||||
testId="upload"
|
||||
ariaLabel="Upload File"
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export function SecurityAnalyzerInput({
|
||||
</label>
|
||||
<Autocomplete
|
||||
isDisabled={isDisabled}
|
||||
isRequired
|
||||
id="security-analyzer"
|
||||
name="security-analyzer"
|
||||
aria-label="Security Analyzer"
|
||||
|
||||
@@ -4,7 +4,7 @@ interface InvariantLogoIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function InvariantLogoIcon({ className }: InvariantLogoIconProps): JSX.Element {
|
||||
function InvariantLogoIcon({ className }: InvariantLogoIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="39"
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useGetTraces } from "#/hooks/query/use-get-traces";
|
||||
|
||||
type SectionType = "logs" | "policy" | "settings";
|
||||
|
||||
function SecurityInvariant(): JSX.Element {
|
||||
function SecurityInvariant() {
|
||||
const { t } = useTranslation();
|
||||
const { logs } = useSelector((state: RootState) => state.securityAnalyzer);
|
||||
|
||||
@@ -122,7 +122,7 @@ function SecurityInvariant(): JSX.Element {
|
||||
[],
|
||||
);
|
||||
|
||||
const sections: { [key in SectionType]: JSX.Element } = {
|
||||
const sections: Record<SectionType, React.ReactNode> = {
|
||||
logs: (
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
|
||||
@@ -7,11 +7,7 @@ import { getDefaultSettings, Settings } from "#/services/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
|
||||
import { DangerModal } from "../confirmation-modals/danger-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
extractSettings,
|
||||
saveSettingsView,
|
||||
updateSettingsVersion,
|
||||
} from "#/utils/settings-utils";
|
||||
import { extractSettings, saveSettingsView } from "#/utils/settings-utils";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
@@ -24,7 +20,6 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
|
||||
import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@@ -45,7 +40,6 @@ export function SettingsForm({
|
||||
}: SettingsFormProps) {
|
||||
const { saveSettings } = useSettings();
|
||||
const endSession = useEndSession();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
@@ -92,14 +86,13 @@ export function SettingsForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmission = (formData: FormData) => {
|
||||
const handleFormSubmission = async (formData: FormData) => {
|
||||
const keys = Array.from(formData.keys());
|
||||
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
|
||||
const newSettings = extractSettings(formData);
|
||||
|
||||
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
|
||||
updateSettingsVersion(logout);
|
||||
saveSettings(newSettings);
|
||||
await saveSettings(newSettings);
|
||||
resetOngoingSession();
|
||||
|
||||
posthog.capture("settings_saved", {
|
||||
@@ -108,8 +101,8 @@ export function SettingsForm({
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmResetSettings = () => {
|
||||
saveSettings(getDefaultSettings());
|
||||
const handleConfirmResetSettings = async () => {
|
||||
await saveSettings(getDefaultSettings());
|
||||
resetOngoingSession();
|
||||
posthog.capture("settings_reset");
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "#/state/initial-query-slice";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
|
||||
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
@@ -22,13 +21,17 @@ import { cn } from "#/utils/utils";
|
||||
import { AttachImageLabel } from "../features/images/attach-image-label";
|
||||
import { ImageCarousel } from "../features/images/image-carousel";
|
||||
import { UploadImageInput } from "../features/images/upload-image-input";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
|
||||
export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
interface TaskFormProps {
|
||||
ref: React.RefObject<HTMLFormElement | null>;
|
||||
}
|
||||
|
||||
export function TaskForm({ ref }: TaskFormProps) {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const { selectedRepository, files } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
@@ -45,7 +48,6 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
return OpenHands.newConversation({
|
||||
githubToken: gitHubToken || undefined,
|
||||
selectedRepository: selectedRepository || undefined,
|
||||
args: settings || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: ({ conversation_id: conversationId }, { q }) => {
|
||||
@@ -88,7 +90,9 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const q = formData.get("q")?.toString();
|
||||
newConversationMutation.mutate({ q });
|
||||
if (q?.trim()) {
|
||||
newConversationMutation.mutate({ q });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -110,32 +114,35 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
name="q"
|
||||
onSubmit={() => {
|
||||
if (typeof ref !== "function") ref?.current?.requestSubmit();
|
||||
}}
|
||||
onChange={(message) => setText(message)}
|
||||
onFocus={() => setInputIsFocused(true)}
|
||||
onBlur={() => setInputIsFocused(false)}
|
||||
onImagePaste={async (imageFiles) => {
|
||||
const promises = imageFiles.map(convertImageToBase64);
|
||||
const base64Images = await Promise.all(promises);
|
||||
base64Images.forEach((base64) => {
|
||||
dispatch(addFile(base64));
|
||||
});
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={text}
|
||||
maxRows={15}
|
||||
showButton={!!text}
|
||||
className="text-[17px] leading-5 py-[17px]"
|
||||
buttonClassName="pb-[17px]"
|
||||
disabled={
|
||||
navigation.state === "submitting" ||
|
||||
newConversationMutation.isPending
|
||||
}
|
||||
/>
|
||||
{newConversationMutation.isPending ? (
|
||||
<div className="flex justify-center py-[17px]">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<ChatInput
|
||||
name="q"
|
||||
onSubmit={() => {
|
||||
if (typeof ref !== "function") ref?.current?.requestSubmit();
|
||||
}}
|
||||
onChange={(message) => setText(message)}
|
||||
onFocus={() => setInputIsFocused(true)}
|
||||
onBlur={() => setInputIsFocused(false)}
|
||||
onImagePaste={async (imageFiles) => {
|
||||
const promises = imageFiles.map(convertImageToBase64);
|
||||
const base64Images = await Promise.all(promises);
|
||||
base64Images.forEach((base64) => {
|
||||
dispatch(addFile(base64));
|
||||
});
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={text}
|
||||
maxRows={15}
|
||||
showButton={!!text}
|
||||
className="text-[17px] leading-5 py-[17px]"
|
||||
buttonClassName="pb-[17px]"
|
||||
disabled={navigation.state === "submitting"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<UploadImageInput
|
||||
@@ -157,6 +164,4 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TaskForm.displayName = "TaskForm";
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
[gitHubTokenState],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
}
|
||||
|
||||
function useAuth() {
|
||||
|
||||
@@ -24,11 +24,7 @@ export function ConversationProvider({
|
||||
|
||||
const value = useMemo(() => ({ conversationId }), [conversationId]);
|
||||
|
||||
return (
|
||||
<ConversationContext.Provider value={value}>
|
||||
{children}
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
return <ConversationContext value={value}>{children}</ConversationContext>;
|
||||
}
|
||||
|
||||
export function useConversation() {
|
||||
|
||||
@@ -24,10 +24,6 @@ interface FilesContextType {
|
||||
setFileContent: (path: string, content: string) => void;
|
||||
selectedPath: string | null;
|
||||
setSelectedPath: (path: string | null) => void;
|
||||
modifiedFiles: Record<string, string>;
|
||||
modifyFileContent: (path: string, content: string) => void;
|
||||
saveFileContent: (path: string) => string | undefined;
|
||||
discardChanges: (path: string) => void;
|
||||
}
|
||||
|
||||
const FilesContext = React.createContext<FilesContextType | undefined>(
|
||||
@@ -41,49 +37,12 @@ interface FilesProviderProps {
|
||||
function FilesProvider({ children }: FilesProviderProps) {
|
||||
const [paths, setPaths] = React.useState<string[]>([]);
|
||||
const [files, setFiles] = React.useState<Record<string, string>>({});
|
||||
const [modifiedFiles, setModifiedFiles] = React.useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [selectedPath, setSelectedPath] = React.useState<string | null>(null);
|
||||
|
||||
const setFileContent = React.useCallback((path: string, content: string) => {
|
||||
setFiles((prev) => ({ ...prev, [path]: content }));
|
||||
}, []);
|
||||
|
||||
const modifyFileContent = React.useCallback(
|
||||
(path: string, content: string) => {
|
||||
if (files[path] !== content) {
|
||||
setModifiedFiles((prev) => ({ ...prev, [path]: content }));
|
||||
} else {
|
||||
const newModifiedFiles = { ...modifiedFiles };
|
||||
delete newModifiedFiles[path];
|
||||
setModifiedFiles(newModifiedFiles);
|
||||
}
|
||||
},
|
||||
[files, modifiedFiles],
|
||||
);
|
||||
|
||||
const discardChanges = React.useCallback((path: string) => {
|
||||
setModifiedFiles((prev) => {
|
||||
const newModifiedFiles = { ...prev };
|
||||
delete newModifiedFiles[path];
|
||||
return newModifiedFiles;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveFileContent = React.useCallback(
|
||||
(path: string): string | undefined => {
|
||||
const content = modifiedFiles[path];
|
||||
if (content) {
|
||||
setFiles((prev) => ({ ...prev, [path]: content }));
|
||||
discardChanges(path);
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
[files, modifiedFiles, selectedPath, discardChanges],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
paths,
|
||||
@@ -92,28 +51,11 @@ function FilesProvider({ children }: FilesProviderProps) {
|
||||
setFileContent,
|
||||
selectedPath,
|
||||
setSelectedPath,
|
||||
modifiedFiles,
|
||||
modifyFileContent,
|
||||
saveFileContent,
|
||||
discardChanges,
|
||||
}),
|
||||
[
|
||||
paths,
|
||||
setPaths,
|
||||
files,
|
||||
setFileContent,
|
||||
selectedPath,
|
||||
setSelectedPath,
|
||||
modifiedFiles,
|
||||
modifyFileContent,
|
||||
saveFileContent,
|
||||
discardChanges,
|
||||
],
|
||||
[paths, setPaths, files, setFileContent, selectedPath, setSelectedPath],
|
||||
);
|
||||
|
||||
return (
|
||||
<FilesContext.Provider value={value}>{children}</FilesContext.Provider>
|
||||
);
|
||||
return <FilesContext value={value}>{children}</FilesContext>;
|
||||
}
|
||||
|
||||
function useFiles() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getSettings,
|
||||
Settings,
|
||||
saveSettings as updateAndSaveSettingsToLocalStorage,
|
||||
saveSettings,
|
||||
settingsAreUpToDate as checkIfSettingsAreUpToDate,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "#/services/settings";
|
||||
@@ -33,8 +33,8 @@ function SettingsProvider({ children }: React.PropsWithChildren) {
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const saveSettings = (newSettings: Partial<Settings>) => {
|
||||
updateAndSaveSettingsToLocalStorage(newSettings);
|
||||
const handleSaveSettings = async (newSettings: Partial<Settings>) => {
|
||||
await saveSettings(newSettings);
|
||||
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY });
|
||||
setSettingsAreUpToDate(checkIfSettingsAreUpToDate());
|
||||
};
|
||||
@@ -49,16 +49,12 @@ function SettingsProvider({ children }: React.PropsWithChildren) {
|
||||
() => ({
|
||||
settings,
|
||||
settingsAreUpToDate,
|
||||
saveSettings,
|
||||
saveSettings: handleSaveSettings,
|
||||
}),
|
||||
[settings, settingsAreUpToDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={value}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
return <SettingsContext value={value}>{children}</SettingsContext>;
|
||||
}
|
||||
|
||||
function useSettings() {
|
||||
|
||||
@@ -151,11 +151,7 @@ export function WsClientProvider({
|
||||
[status, messageRateHandler.isUnderThreshold, events],
|
||||
);
|
||||
|
||||
return (
|
||||
<WsClientContext.Provider value={value}>
|
||||
{children}
|
||||
</WsClientContext.Provider>
|
||||
);
|
||||
return <WsClientContext value={value}>{children}</WsClientContext>;
|
||||
}
|
||||
|
||||
export function useWsClient() {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type SaveFileArgs = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const useSaveFile = () => {
|
||||
const { conversationId } = useConversation();
|
||||
return useMutation({
|
||||
mutationFn: ({ path, content }: SaveFileArgs) =>
|
||||
OpenHands.saveFile(conversationId, path, content),
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type UploadFilesArgs = {
|
||||
files: File[];
|
||||
};
|
||||
|
||||
export const useUploadFiles = () => {
|
||||
const { conversationId } = useConversation();
|
||||
return useMutation({
|
||||
mutationFn: ({ files }: UploadFilesArgs) =>
|
||||
OpenHands.uploadFiles(conversationId, files),
|
||||
});
|
||||
};
|
||||
@@ -20,7 +20,7 @@ export function useDownloadProgress(
|
||||
const [progress, setProgress] =
|
||||
useState<DownloadProgressState>(INITIAL_PROGRESS);
|
||||
const progressRef = useRef<DownloadProgressState>(INITIAL_PROGRESS);
|
||||
const abortController = useRef<AbortController>();
|
||||
const abortController = useRef<AbortController>(null);
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
// Create AbortController on mount
|
||||
@@ -31,7 +31,7 @@ export function useDownloadProgress(
|
||||
progressRef.current = INITIAL_PROGRESS;
|
||||
return () => {
|
||||
controller.abort();
|
||||
abortController.current = undefined;
|
||||
abortController.current = null;
|
||||
};
|
||||
}, []); // Empty deps array - only run on mount/unmount
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
|
||||
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement>) {
|
||||
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
|
||||
// for auto-scroll
|
||||
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
@@ -36,15 +36,15 @@ function Home() {
|
||||
return (
|
||||
<div
|
||||
data-testid="root-index"
|
||||
className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto"
|
||||
className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
|
||||
>
|
||||
<HeroHeading />
|
||||
<div className="flex flex-col gap-8 w-[600px] items-center">
|
||||
<div className="flex flex-col gap-8 w-full md:w-[600px] items-center">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<TaskForm ref={formRef} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 w-full">
|
||||
<div className="flex gap-4 w-full flex-col md:flex-row">
|
||||
<GitHubRepositoriesSuggestionBox
|
||||
handleSubmit={() => formRef.current?.requestSubmit()}
|
||||
repositories={
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useRouteError } from "react-router";
|
||||
import { editor } from "monaco-editor";
|
||||
import { EditorProps } from "@monaco-editor/react";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import CodeEditorComponent from "../../components/features/editor/code-editor-component";
|
||||
import { useFiles } from "#/context/files";
|
||||
import { useSaveFile } from "#/hooks/mutation/use-save-file";
|
||||
import { ASSET_FILE_TYPES } from "./constants";
|
||||
import { EditorActions } from "#/components/features/editor/editor-actions";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
|
||||
import { useFiles } from "#/context/files";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -23,90 +16,91 @@ export function ErrorBoundary() {
|
||||
);
|
||||
}
|
||||
|
||||
function CodeEditor() {
|
||||
const {
|
||||
selectedPath,
|
||||
modifiedFiles,
|
||||
saveFileContent: saveNewFileContent,
|
||||
discardChanges,
|
||||
} = useFiles();
|
||||
function getLanguageFromPath(path: string): string {
|
||||
const extension = path.split(".").pop()?.toLowerCase();
|
||||
switch (extension) {
|
||||
case "js":
|
||||
case "jsx":
|
||||
return "javascript";
|
||||
case "ts":
|
||||
case "tsx":
|
||||
return "typescript";
|
||||
case "py":
|
||||
return "python";
|
||||
case "html":
|
||||
return "html";
|
||||
case "css":
|
||||
return "css";
|
||||
case "json":
|
||||
return "json";
|
||||
case "md":
|
||||
return "markdown";
|
||||
case "yml":
|
||||
case "yaml":
|
||||
return "yaml";
|
||||
case "sh":
|
||||
case "bash":
|
||||
return "bash";
|
||||
case "dockerfile":
|
||||
return "dockerfile";
|
||||
case "rs":
|
||||
return "rust";
|
||||
case "go":
|
||||
return "go";
|
||||
case "java":
|
||||
return "java";
|
||||
case "cpp":
|
||||
case "cc":
|
||||
case "cxx":
|
||||
return "cpp";
|
||||
case "c":
|
||||
return "c";
|
||||
case "rb":
|
||||
return "ruby";
|
||||
case "php":
|
||||
return "php";
|
||||
case "sql":
|
||||
return "sql";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
function FileViewer() {
|
||||
const [fileExplorerIsOpen, setFileExplorerIsOpen] = React.useState(true);
|
||||
const editorRef = React.useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
const { mutate: saveFile } = useSaveFile();
|
||||
const { selectedPath, files } = useFiles();
|
||||
|
||||
const toggleFileExplorer = () => {
|
||||
setFileExplorerIsOpen((prev) => !prev);
|
||||
editorRef.current?.layout({ width: 0, height: 0 });
|
||||
};
|
||||
|
||||
const handleEditorDidMount: EditorProps["onMount"] = (e, monaco) => {
|
||||
editorRef.current = e;
|
||||
|
||||
monaco.editor.defineTheme("oh-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
"editor.background": "#171717",
|
||||
},
|
||||
});
|
||||
monaco.editor.setTheme("oh-dark");
|
||||
};
|
||||
|
||||
const agentState = useSelector(
|
||||
(state: RootState) => state.agent.curAgentState,
|
||||
);
|
||||
|
||||
// Code editing is only allowed when the agent is paused, finished, or awaiting user input (server rules)
|
||||
const isEditingAllowed = React.useMemo(
|
||||
() =>
|
||||
agentState === AgentState.PAUSED ||
|
||||
agentState === AgentState.FINISHED ||
|
||||
agentState === AgentState.AWAITING_USER_INPUT,
|
||||
[agentState],
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (selectedPath) {
|
||||
const content = modifiedFiles[selectedPath];
|
||||
if (content) {
|
||||
saveFile({ path: selectedPath, content });
|
||||
saveNewFileContent(selectedPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (selectedPath) discardChanges(selectedPath);
|
||||
};
|
||||
|
||||
const isAssetFileType = selectedPath
|
||||
? ASSET_FILE_TYPES.some((ext) => selectedPath.endsWith(ext))
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-neutral-900 relative">
|
||||
<FileExplorer isOpen={fileExplorerIsOpen} onToggle={toggleFileExplorer} />
|
||||
<div className="w-full">
|
||||
{selectedPath && !isAssetFileType && (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
{selectedPath && (
|
||||
<div className="flex w-full items-center justify-between self-end p-2">
|
||||
<span className="text-sm text-neutral-500">{selectedPath}</span>
|
||||
<EditorActions
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
isDisabled={!isEditingAllowed || !modifiedFiles[selectedPath]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CodeEditorComponent
|
||||
onMount={handleEditorDidMount}
|
||||
isReadOnly={!isEditingAllowed}
|
||||
/>
|
||||
{selectedPath && files[selectedPath] && (
|
||||
<div className="p-4 flex-1 overflow-auto">
|
||||
<SyntaxHighlighter
|
||||
language={getLanguageFromPath(selectedPath)}
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
background: "#171717",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{files[selectedPath]}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeEditor;
|
||||
export default FileViewer;
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { getGitHubTokenCommand } from "#/services/terminal-service";
|
||||
import { setImportedProjectZip } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { base64ToBlob } from "#/utils/base64-to-blob";
|
||||
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
|
||||
import { useGitHubUser } from "../../../hooks/query/use-github-user";
|
||||
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
@@ -17,48 +13,19 @@ export const useHandleRuntimeActive = () => {
|
||||
const { send } = useWsClient();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: user } = useGitHubUser();
|
||||
const { mutate: uploadFiles } = useUploadFiles();
|
||||
|
||||
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const { importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
const userId = React.useMemo(() => {
|
||||
if (user && !isGitHubErrorReponse(user)) return user.id;
|
||||
return null;
|
||||
}, [user]);
|
||||
|
||||
const handleUploadFiles = (zip: string) => {
|
||||
const blob = base64ToBlob(zip);
|
||||
const file = new File([blob], "imported-project.zip", {
|
||||
type: blob.type,
|
||||
});
|
||||
uploadFiles(
|
||||
{ files: [file] },
|
||||
{
|
||||
onError: () => {
|
||||
toast.error("Failed to upload project files.");
|
||||
},
|
||||
},
|
||||
);
|
||||
dispatch(setImportedProjectZip(null));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (runtimeActive && userId && gitHubToken) {
|
||||
// Export if the user valid, this could happen mid-session so it is handled here
|
||||
send(getGitHubTokenCommand(gitHubToken));
|
||||
}
|
||||
}, [userId, gitHubToken, runtimeActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (runtimeActive && importedProjectZip) {
|
||||
handleUploadFiles(importedProjectZip);
|
||||
}
|
||||
}, [runtimeActive, importedProjectZip]);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Container } from "#/components/layout/container";
|
||||
import Security from "#/components/shared/modals/security/security";
|
||||
import { CountBadge } from "#/components/layout/count-badge";
|
||||
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
|
||||
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
|
||||
|
||||
function AppContent() {
|
||||
const { gitHubToken } = useAuth();
|
||||
@@ -62,6 +63,12 @@ function AppContent() {
|
||||
dispatch(clearJupyter());
|
||||
});
|
||||
|
||||
const [isWorkspaceHidden, setIsWorkspaceHidden] = React.useState(false);
|
||||
|
||||
const toggleWorkspace = React.useCallback(() => {
|
||||
setIsWorkspaceHidden((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
isOpen: securityModalIsOpen,
|
||||
onOpen: onSecurityModalOpen,
|
||||
@@ -71,15 +78,31 @@ function AppContent() {
|
||||
return (
|
||||
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
|
||||
<EventHandler>
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto gap-3">
|
||||
<Container className="w-full md:w-[390px] max-h-full relative">
|
||||
<ChatInterface />
|
||||
</Container>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-grow overflow-hidden">
|
||||
<div
|
||||
className={`relative flex-grow mr-5 ${isWorkspaceHidden ? "w-full" : "md:w-[390px]"}`}
|
||||
>
|
||||
<Container className="h-full">
|
||||
<ChatInterface />
|
||||
</Container>
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 ${isWorkspaceHidden ? "-right-4" : "-right-4"} z-10`}
|
||||
>
|
||||
<ToggleWorkspaceIconButton
|
||||
onClick={toggleWorkspace}
|
||||
isHidden={isWorkspaceHidden}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex flex-col grow gap-3">
|
||||
<div
|
||||
className={`hidden md:flex flex-col flex-grow transition-all duration-300 ${
|
||||
isWorkspaceHidden ? "w-0 opacity-0 overflow-hidden" : ""
|
||||
}`}
|
||||
>
|
||||
<Container
|
||||
className="h-2/3"
|
||||
className="flex-grow"
|
||||
labels={[
|
||||
{ label: "Workspace", to: "", icon: <CodeIcon /> },
|
||||
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
import { updateSettingsVersion } from "#/utils/settings-utils";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
|
||||
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
|
||||
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -45,15 +45,13 @@ export function ErrorBoundary() {
|
||||
|
||||
export default function MainApp() {
|
||||
const { gitHubToken } = useAuth();
|
||||
const { settings, settingsAreUpToDate } = useSettings();
|
||||
const { settings } = useSettings();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
|
||||
!localStorage.getItem("analytics-consent"),
|
||||
);
|
||||
|
||||
const [aiConfigModalIsOpen, setAiConfigModalIsOpen] =
|
||||
React.useState(!settingsAreUpToDate);
|
||||
|
||||
const config = useConfig();
|
||||
const { data: isAuthed, isFetching: isFetchingAuth } = useIsAuthed();
|
||||
|
||||
@@ -69,6 +67,10 @@ export default function MainApp() {
|
||||
}
|
||||
}, [settings.LANGUAGE]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateSettingsVersion(logout);
|
||||
}, []);
|
||||
|
||||
const isInWaitlist =
|
||||
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
|
||||
|
||||
@@ -92,13 +94,6 @@ export default function MainApp() {
|
||||
onClose={() => setConsentFormIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{aiConfigModalIsOpen && (
|
||||
<SettingsModal
|
||||
onClose={() => setAiConfigModalIsOpen(false)}
|
||||
data-testid="ai-config-modal"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
|
||||
export const LATEST_SETTINGS_VERSION = 4;
|
||||
export const LATEST_SETTINGS_VERSION = 5;
|
||||
|
||||
export type Settings = {
|
||||
LLM_MODEL: string;
|
||||
@@ -45,54 +45,8 @@ export const getCurrentSettingsVersion = () => {
|
||||
export const settingsAreUpToDate = () =>
|
||||
getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
|
||||
|
||||
export const maybeMigrateSettings = (logout: () => void) => {
|
||||
// Sometimes we ship major changes, like a new default agent.
|
||||
// In this case, we may want to override a previous choice made by the user.
|
||||
const currentVersion = getCurrentSettingsVersion();
|
||||
|
||||
if (currentVersion < 1) {
|
||||
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
|
||||
}
|
||||
if (currentVersion < 2) {
|
||||
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
|
||||
if (customModel) {
|
||||
localStorage.setItem("LLM_MODEL", customModel);
|
||||
}
|
||||
localStorage.removeItem("CUSTOM_LLM_MODEL");
|
||||
localStorage.removeItem("USING_CUSTOM_MODEL");
|
||||
}
|
||||
if (currentVersion < 3) {
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
if (currentVersion < 4) {
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default settings
|
||||
*/
|
||||
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
|
||||
|
||||
/**
|
||||
* Get the settings from the server or use the default settings if not found
|
||||
*/
|
||||
export const getSettings = async (): Promise<Settings> => {
|
||||
const { data: apiSettings } =
|
||||
await openHands.get<ApiSettings>("/api/settings");
|
||||
if (apiSettings != null) {
|
||||
return {
|
||||
LLM_MODEL: apiSettings.llm_model,
|
||||
LLM_BASE_URL: apiSettings.llm_base_url,
|
||||
AGENT: apiSettings.agent,
|
||||
LANGUAGE: apiSettings.language,
|
||||
CONFIRMATION_MODE: apiSettings.confirmation_mode,
|
||||
SECURITY_ANALYZER: apiSettings.security_analyzer,
|
||||
LLM_API_KEY: "",
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: localStorage settings are deprecated. Remove this after 1/31/2025
|
||||
export const getLocalStorageSettings = (): Settings => {
|
||||
const llmModel = localStorage.getItem("LLM_MODEL");
|
||||
const baseUrl = localStorage.getItem("LLM_BASE_URL");
|
||||
const agent = localStorage.getItem("AGENT");
|
||||
@@ -133,7 +87,61 @@ export const saveSettings = async (
|
||||
const { data } = await openHands.post("/api/settings", apiSettings);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error saving settings:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const maybeMigrateSettings = async (logout: () => void) => {
|
||||
// Sometimes we ship major changes, like a new default agent.
|
||||
// In this case, we may want to override a previous choice made by the user.
|
||||
const currentVersion = getCurrentSettingsVersion();
|
||||
|
||||
if (currentVersion < 1) {
|
||||
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
|
||||
}
|
||||
if (currentVersion < 2) {
|
||||
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
|
||||
if (customModel) {
|
||||
localStorage.setItem("LLM_MODEL", customModel);
|
||||
}
|
||||
localStorage.removeItem("CUSTOM_LLM_MODEL");
|
||||
localStorage.removeItem("USING_CUSTOM_MODEL");
|
||||
}
|
||||
if (currentVersion < 3) {
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
if (currentVersion < 4) {
|
||||
logout();
|
||||
}
|
||||
|
||||
if (currentVersion < 5) {
|
||||
const localSettings = getLocalStorageSettings();
|
||||
await saveSettings(localSettings);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default settings
|
||||
*/
|
||||
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
|
||||
|
||||
/**
|
||||
* Get the settings from the server or use the default settings if not found
|
||||
*/
|
||||
export const getSettings = async (): Promise<Settings> => {
|
||||
const { data: apiSettings } =
|
||||
await openHands.get<ApiSettings>("/api/settings");
|
||||
if (apiSettings != null) {
|
||||
return {
|
||||
LLM_MODEL: apiSettings.llm_model,
|
||||
LLM_BASE_URL: apiSettings.llm_base_url,
|
||||
AGENT: apiSettings.agent,
|
||||
LANGUAGE: apiSettings.language,
|
||||
CONFIRMATION_MODE: apiSettings.confirmation_mode,
|
||||
SECURITY_ANALYZER: apiSettings.security_analyzer,
|
||||
LLM_API_KEY: "",
|
||||
};
|
||||
}
|
||||
return getLocalStorageSettings();
|
||||
};
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { ErrorToast } from "#/components/shared/error-toast";
|
||||
|
||||
export const displayErrorToast = (error: string) =>
|
||||
toast((t) => <ErrorToast id={t.id} error={error} />, {
|
||||
style: {
|
||||
background: "#C63143",
|
||||
color: "#fff",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
lineHeight: "20px",
|
||||
borderRadius: "4px",
|
||||
width: "336px",
|
||||
},
|
||||
duration: Infinity,
|
||||
position: "bottom-right",
|
||||
});
|
||||
@@ -82,9 +82,9 @@ const saveSettingsView = (view: "basic" | "advanced") => {
|
||||
* Updates the settings version in local storage if the current settings are not up to date.
|
||||
* If the settings are outdated, it attempts to migrate them before updating the version.
|
||||
*/
|
||||
const updateSettingsVersion = (logout: () => void) => {
|
||||
const updateSettingsVersion = async (logout: () => void) => {
|
||||
if (!settingsAreUpToDate()) {
|
||||
maybeMigrateSettings(logout);
|
||||
await maybeMigrateSettings(logout);
|
||||
localStorage.setItem(
|
||||
"SETTINGS_VERSION",
|
||||
LATEST_SETTINGS_VERSION.toString(),
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { RenderOptions, render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { AppStore, RootState, rootReducer } from "./src/store";
|
||||
import { vi } from "vitest";
|
||||
import { AppStore, RootState, rootReducer } from "./src/store";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { SettingsProvider } from "#/context/settings-context";
|
||||
import { ConversationProvider } from "#/context/conversation-context";
|
||||
@@ -26,22 +24,20 @@ vi.mock("react-router", async () => {
|
||||
});
|
||||
|
||||
// Initialize i18n for tests
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
ns: ["translation"],
|
||||
defaultNS: "translation",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {},
|
||||
},
|
||||
i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
ns: ["translation"],
|
||||
defaultNS: "translation",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {},
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
|
||||
configureStore({
|
||||
@@ -67,16 +63,14 @@ export function renderWithProviders(
|
||||
...renderOptions
|
||||
}: ExtendedRenderOptions = {},
|
||||
) {
|
||||
function Wrapper({ children }: PropsWithChildren<object>): JSX.Element {
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<SettingsProvider>
|
||||
<AuthProvider>
|
||||
<ConversationProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{children}
|
||||
</I18nextProvider>
|
||||
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
|
||||
</ConversationProvider>
|
||||
</AuthProvider>
|
||||
</SettingsProvider>
|
||||
|
||||
@@ -83,6 +83,7 @@ class CodeActAgent(Agent):
|
||||
- llm (LLM): The llm to be used by this agent
|
||||
"""
|
||||
super().__init__(llm, config)
|
||||
self.pending_actions: deque[Action] = deque()
|
||||
self.reset()
|
||||
|
||||
self.mock_function_calling = False
|
||||
@@ -110,8 +111,6 @@ class CodeActAgent(Agent):
|
||||
disabled_microagents=self.config.disabled_microagents,
|
||||
)
|
||||
|
||||
self.pending_actions: deque[Action] = deque()
|
||||
|
||||
def get_action_message(
|
||||
self,
|
||||
action: Action,
|
||||
@@ -340,6 +339,7 @@ class CodeActAgent(Agent):
|
||||
def reset(self) -> None:
|
||||
"""Resets the CodeAct Agent."""
|
||||
super().reset()
|
||||
self.pending_actions.clear()
|
||||
|
||||
def step(self, state: State) -> Action:
|
||||
"""Performs one step using the CodeAct Agent.
|
||||
|
||||
@@ -249,9 +249,14 @@ def auto_continue_response(
|
||||
try_parse: Callable[[Action | None], str] | None = None,
|
||||
) -> str:
|
||||
"""Default function to generate user responses.
|
||||
Returns 'continue' to tell the agent to proceed without asking for more input.
|
||||
Tell the agent to proceed without asking for more input, or finish the interaction.
|
||||
"""
|
||||
return 'continue'
|
||||
message = (
|
||||
'Please continue on whatever approach you think is suitable.\n'
|
||||
'If you think you have solved the task, please finish the interaction.\n'
|
||||
'IMPORTANT: YOU SHOULD NEVER ASK FOR HUMAN RESPONSE.\n'
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -62,6 +62,8 @@ class Message(BaseModel):
|
||||
# - tool execution result (to LLM)
|
||||
tool_call_id: str | None = None
|
||||
name: str | None = None # name of the tool
|
||||
# force string serializer
|
||||
force_string_serializer: bool = False
|
||||
|
||||
@property
|
||||
def contains_image(self) -> bool:
|
||||
@@ -73,7 +75,9 @@ class Message(BaseModel):
|
||||
# - into a single string: for providers that don't support list of content items (e.g. no vision, no tool calls)
|
||||
# - into a list of content items: the new APIs of providers with vision/prompt caching/tool calls
|
||||
# NOTE: remove this when litellm or providers support the new API
|
||||
if self.cache_enabled or self.vision_enabled or self.function_calling_enabled:
|
||||
if not self.force_string_serializer and (
|
||||
self.cache_enabled or self.vision_enabled or self.function_calling_enabled
|
||||
):
|
||||
return self._list_serializer()
|
||||
# some providers, like HF and Groq/llama, don't support a list here, but a single string
|
||||
return self._string_serializer()
|
||||
|
||||
@@ -122,6 +122,12 @@ class LLM(RetryMixin, DebugMixin):
|
||||
if self.is_function_calling_active():
|
||||
logger.debug('LLM: model supports function calling')
|
||||
|
||||
# Compatibility flag: use string serializer for DeepSeek models
|
||||
# See this issue: https://github.com/All-Hands-AI/OpenHands/issues/5818
|
||||
self._use_string_serializer = False
|
||||
if 'deepseek' in self.config.model:
|
||||
self._use_string_serializer = True
|
||||
|
||||
# if using a custom tokenizer, make sure it's loaded and accessible in the format expected by litellm
|
||||
if self.config.custom_tokenizer is not None:
|
||||
self.tokenizer = create_pretrained_tokenizer(self.config.custom_tokenizer)
|
||||
@@ -618,6 +624,8 @@ class LLM(RetryMixin, DebugMixin):
|
||||
message.cache_enabled = self.is_caching_prompt_active()
|
||||
message.vision_enabled = self.vision_is_active()
|
||||
message.function_calling_enabled = self.is_function_calling_active()
|
||||
if 'deepseek' in self.config.model:
|
||||
message.force_string_serializer = True
|
||||
|
||||
# let pydantic handle the serialization
|
||||
return [message.model_dump() for message in messages]
|
||||
|
||||
@@ -13,6 +13,7 @@ from openhands.events.observation import (
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.events.stream import AsyncEventStreamWrapper
|
||||
from openhands.server.routes.settings import SettingsStoreImpl
|
||||
from openhands.server.session.manager import ConversationDoesNotExistError
|
||||
from openhands.server.shared import config, openhands_config, session_manager, sio
|
||||
from openhands.server.types import AppMode
|
||||
@@ -32,10 +33,12 @@ async def connect(connection_id: str, environ, auth):
|
||||
logger.error('No conversation_id in query params')
|
||||
raise ConnectionRefusedError('No conversation_id in query params')
|
||||
|
||||
github_token = ''
|
||||
if openhands_config.app_mode != AppMode.OSS:
|
||||
user_id = ''
|
||||
if auth and 'github_token' in auth:
|
||||
with Github(auth['github_token']) as g:
|
||||
github_token = auth['github_token']
|
||||
with Github(github_token) as g:
|
||||
gh_user = await call_sync_from_async(g.get_user)
|
||||
user_id = gh_user.id
|
||||
|
||||
@@ -51,9 +54,15 @@ async def connect(connection_id: str, environ, auth):
|
||||
f'User {user_id} is not allowed to join conversation {conversation_id}'
|
||||
)
|
||||
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
settings = await settings_store.load()
|
||||
|
||||
if not settings:
|
||||
raise ConnectionRefusedError('Settings not found')
|
||||
|
||||
try:
|
||||
event_stream = await session_manager.join_conversation(
|
||||
conversation_id, connection_id
|
||||
conversation_id, connection_id, settings
|
||||
)
|
||||
except ConversationDoesNotExistError:
|
||||
logger.error(f'Conversation {conversation_id} does not exist')
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, status
|
||||
from fastapi import APIRouter, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -16,10 +14,13 @@ SettingsStoreImpl = get_impl(SettingsStore, openhands_config.settings_store_clas
|
||||
|
||||
@app.get('/settings')
|
||||
async def load_settings(
|
||||
github_auth: Annotated[str | None, Header()] = None,
|
||||
request: Request,
|
||||
) -> Settings | None:
|
||||
github_token = ''
|
||||
if hasattr(request.state, 'github_token'):
|
||||
github_token = request.state.github_token
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_auth)
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
settings = await settings_store.load()
|
||||
if settings:
|
||||
# For security reasons we don't ever send the api key to the client
|
||||
@@ -35,18 +36,24 @@ async def load_settings(
|
||||
|
||||
@app.post('/settings')
|
||||
async def store_settings(
|
||||
request: Request,
|
||||
settings: Settings,
|
||||
github_auth: Annotated[str | None, Header()] = None,
|
||||
) -> bool:
|
||||
) -> JSONResponse:
|
||||
github_token = ''
|
||||
if hasattr(request.state, 'github_token'):
|
||||
github_token = request.state.github_token
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_auth)
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
existing_settings = await settings_store.load()
|
||||
if existing_settings:
|
||||
settings = Settings(**{**existing_settings.__dict__, **settings.__dict__})
|
||||
if settings.llm_api_key is None:
|
||||
settings.llm_api_key = existing_settings.llm_api_key
|
||||
await settings_store.store(settings)
|
||||
return True
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Settings stored'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid token: {e}')
|
||||
return JSONResponse(
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
import socketio
|
||||
|
||||
@@ -10,8 +11,8 @@ from openhands.core.exceptions import AgentRuntimeUnavailableError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.stream import EventStream, session_exists
|
||||
from openhands.server.session.conversation import Conversation
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.server.session.session import ROOM_KEY, Session
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
@@ -27,6 +28,14 @@ class ConversationDoesNotExistError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SessionIsRunningCheck:
|
||||
request_id: str
|
||||
request_sids: list[str]
|
||||
running_sids: set[str] = field(default_factory=set)
|
||||
flag: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionManager:
|
||||
sio: socketio.AsyncServer
|
||||
@@ -36,7 +45,9 @@ class SessionManager:
|
||||
local_connection_id_to_session_id: dict[str, str] = field(default_factory=dict)
|
||||
_last_alive_timestamps: dict[str, float] = field(default_factory=dict)
|
||||
_redis_listen_task: asyncio.Task | None = None
|
||||
_session_is_running_flags: dict[str, asyncio.Event] = field(default_factory=dict)
|
||||
_session_is_running_checks: dict[str, _SessionIsRunningCheck] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
_active_conversations: dict[str, tuple[Conversation, int]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
@@ -97,27 +108,41 @@ class SessionManager:
|
||||
async def _process_message(self, message: dict):
|
||||
data = json.loads(message['data'])
|
||||
logger.debug(f'got_published_message:{message}')
|
||||
sid = data['sid']
|
||||
message_type = data['message_type']
|
||||
if message_type == 'event':
|
||||
sid = data['sid']
|
||||
session = self._local_agent_loops_by_sid.get(sid)
|
||||
if session:
|
||||
await session.dispatch(data['data'])
|
||||
elif message_type == 'is_session_running':
|
||||
# Another node in the cluster is asking if the current node is running the session given.
|
||||
session = self._local_agent_loops_by_sid.get(sid)
|
||||
if session:
|
||||
request_id = data['request_id']
|
||||
sids = [
|
||||
sid for sid in data['sids'] if sid in self._local_agent_loops_by_sid
|
||||
]
|
||||
if sids:
|
||||
await self._get_redis_client().publish(
|
||||
'oh_event',
|
||||
json.dumps({'sid': sid, 'message_type': 'session_is_running'}),
|
||||
json.dumps(
|
||||
{
|
||||
'request_id': request_id,
|
||||
'sids': sids,
|
||||
'message_type': 'session_is_running',
|
||||
}
|
||||
),
|
||||
)
|
||||
elif message_type == 'session_is_running':
|
||||
self._last_alive_timestamps[sid] = time.time()
|
||||
flag = self._session_is_running_flags.get(sid)
|
||||
if flag:
|
||||
flag.set()
|
||||
request_id = data['request_id']
|
||||
for sid in data['sids']:
|
||||
self._last_alive_timestamps[sid] = time.time()
|
||||
check = self._session_is_running_checks.get(request_id)
|
||||
if check:
|
||||
check.running_sids.update(data['sids'])
|
||||
if len(check.request_sids) == len(check.running_sids):
|
||||
check.flag.set()
|
||||
elif message_type == 'has_remote_connections_query':
|
||||
# Another node in the cluster is asking if the current node is connected to a session
|
||||
sid = data['sid']
|
||||
required = sid in self.local_connection_id_to_session_id.values()
|
||||
if required:
|
||||
await self._get_redis_client().publish(
|
||||
@@ -127,12 +152,14 @@ class SessionManager:
|
||||
),
|
||||
)
|
||||
elif message_type == 'has_remote_connections_response':
|
||||
sid = data['sid']
|
||||
flag = self._has_remote_connections_flags.get(sid)
|
||||
if flag:
|
||||
flag.set()
|
||||
elif message_type == 'session_closing':
|
||||
# Session closing event - We only get this in the event of graceful shutdown,
|
||||
# which can't be guaranteed - nodes can simply vanish unexpectedly!
|
||||
sid = data['sid']
|
||||
logger.debug(f'session_closing:{sid}')
|
||||
for (
|
||||
connection_id,
|
||||
@@ -178,13 +205,13 @@ class SessionManager:
|
||||
self._active_conversations[sid] = (c, 1)
|
||||
return c
|
||||
|
||||
async def join_conversation(self, sid: str, connection_id: str) -> EventStream:
|
||||
async def join_conversation(self, sid: str, connection_id: str, settings: Settings):
|
||||
logger.info(f'join_conversation:{sid}:{connection_id}')
|
||||
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
|
||||
self.local_connection_id_to_session_id[connection_id] = sid
|
||||
event_stream = await self._get_event_stream(sid)
|
||||
if not event_stream:
|
||||
return await self.maybe_start_agent_loop(sid)
|
||||
return await self.maybe_start_agent_loop(sid, settings)
|
||||
return event_stream
|
||||
|
||||
async def detach_from_conversation(self, conversation: Conversation):
|
||||
@@ -234,33 +261,47 @@ class SessionManager:
|
||||
logger.warning('error_cleaning_detached_conversations', exc_info=True)
|
||||
await asyncio.sleep(_CLEANUP_EXCEPTION_WAIT_TIME)
|
||||
|
||||
async def _is_agent_loop_running(self, sid: str) -> bool:
|
||||
if await self._is_agent_loop_running_locally(sid):
|
||||
async def get_agent_loop_running(self, sids: set[str]) -> set[str]:
|
||||
running_sids = set(sid for sid in sids if sid in self._local_agent_loops_by_sid)
|
||||
check_cluster_sids = [sid for sid in sids if sid not in running_sids]
|
||||
running_cluster_sids = await self.get_agent_loop_running_in_cluster(
|
||||
check_cluster_sids
|
||||
)
|
||||
running_sids.union(running_cluster_sids)
|
||||
return running_sids
|
||||
|
||||
async def is_agent_loop_running(self, sid: str) -> bool:
|
||||
if await self.is_agent_loop_running_locally(sid):
|
||||
return True
|
||||
if await self._is_agent_loop_running_in_cluster(sid):
|
||||
if await self.is_agent_loop_running_in_cluster(sid):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _is_agent_loop_running_locally(self, sid: str) -> bool:
|
||||
if self._local_agent_loops_by_sid.get(sid, None):
|
||||
return True
|
||||
return False
|
||||
async def is_agent_loop_running_locally(self, sid: str) -> bool:
|
||||
return sid in self._local_agent_loops_by_sid
|
||||
|
||||
async def _is_agent_loop_running_in_cluster(self, sid: str) -> bool:
|
||||
async def is_agent_loop_running_in_cluster(self, sid: str) -> bool:
|
||||
running_sids = await self.get_agent_loop_running_in_cluster([sid])
|
||||
return bool(running_sids)
|
||||
|
||||
async def get_agent_loop_running_in_cluster(self, sids: list[str]) -> set[str]:
|
||||
"""As the rest of the cluster if a session is running. Wait a for a short timeout for a reply"""
|
||||
redis_client = self._get_redis_client()
|
||||
if not redis_client:
|
||||
return False
|
||||
return set()
|
||||
|
||||
flag = asyncio.Event()
|
||||
self._session_is_running_flags[sid] = flag
|
||||
request_id = str(uuid4())
|
||||
check = _SessionIsRunningCheck(request_id=request_id, request_sids=sids)
|
||||
self._session_is_running_checks[request_id] = check
|
||||
try:
|
||||
logger.debug(f'publish:is_session_running:{sid}')
|
||||
logger.debug(f'publish:is_session_running:{sids}')
|
||||
await redis_client.publish(
|
||||
'oh_event',
|
||||
json.dumps(
|
||||
{
|
||||
'sid': sid,
|
||||
'request_id': request_id,
|
||||
'sids': sids,
|
||||
'message_type': 'is_session_running',
|
||||
}
|
||||
),
|
||||
@@ -268,13 +309,12 @@ class SessionManager:
|
||||
async with asyncio.timeout(_REDIS_POLL_TIMEOUT):
|
||||
await flag.wait()
|
||||
|
||||
result = flag.is_set()
|
||||
return result
|
||||
return check.running_sids
|
||||
except TimeoutError:
|
||||
# Nobody replied in time
|
||||
return False
|
||||
return check.running_sids
|
||||
finally:
|
||||
self._session_is_running_flags.pop(sid, None)
|
||||
self._session_is_running_checks.pop(request_id, None)
|
||||
|
||||
async def _has_remote_connections(self, sid: str) -> bool:
|
||||
"""As the rest of the cluster if they still want this session running. Wait a for a short timeout for a reply"""
|
||||
@@ -302,18 +342,16 @@ class SessionManager:
|
||||
finally:
|
||||
self._has_remote_connections_flags.pop(sid, None)
|
||||
|
||||
async def maybe_start_agent_loop(
|
||||
self, sid: str, conversation_init_data: ConversationInitData | None = None
|
||||
) -> EventStream:
|
||||
async def maybe_start_agent_loop(self, sid: str, settings: Settings) -> EventStream:
|
||||
logger.info(f'maybe_start_agent_loop:{sid}')
|
||||
session: Session | None = None
|
||||
if not await self._is_agent_loop_running(sid):
|
||||
if not await self.is_agent_loop_running(sid):
|
||||
logger.info(f'start_agent_loop:{sid}')
|
||||
session = Session(
|
||||
sid=sid, file_store=self.file_store, config=self.config, sio=self.sio
|
||||
)
|
||||
self._local_agent_loops_by_sid[sid] = session
|
||||
await session.initialize_agent(conversation_init_data)
|
||||
await session.initialize_agent(settings)
|
||||
|
||||
event_stream = await self._get_event_stream(sid)
|
||||
if not event_stream:
|
||||
@@ -328,7 +366,7 @@ class SessionManager:
|
||||
logger.info(f'found_local_agent_loop:{sid}')
|
||||
return session.agent_session.event_stream
|
||||
|
||||
if await self._is_agent_loop_running_in_cluster(sid):
|
||||
if await self.is_agent_loop_running_in_cluster(sid):
|
||||
logger.info(f'found_remote_agent_loop:{sid}')
|
||||
return EventStream(sid, self.file_store)
|
||||
|
||||
@@ -352,7 +390,7 @@ class SessionManager:
|
||||
next_alive_check = last_alive_at + _CHECK_ALIVE_INTERVAL
|
||||
if (
|
||||
next_alive_check > time.time()
|
||||
or await self._is_agent_loop_running_in_cluster(sid)
|
||||
or await self.is_agent_loop_running_in_cluster(sid)
|
||||
):
|
||||
# Send the event to the other pod
|
||||
await redis_client.publish(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from copy import deepcopy
|
||||
|
||||
@@ -23,9 +22,8 @@ from openhands.events.stream import EventStreamSubscriber
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.server.session.agent_session import AgentSession
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import get_conversation_init_data_filename
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
ROOM_KEY = 'room:{sid}'
|
||||
|
||||
@@ -65,63 +63,33 @@ class Session:
|
||||
self.is_alive = False
|
||||
self.agent_session.close()
|
||||
|
||||
async def _restore_init_data(self, sid: str) -> ConversationInitData:
|
||||
# FIXME: we should not store/restore this data once we have server-side
|
||||
# LLM configs. Should be done by 1/1/2025
|
||||
json_str = await call_sync_from_async(
|
||||
self.file_store.read, get_conversation_init_data_filename(sid)
|
||||
)
|
||||
data = json.loads(json_str)
|
||||
return ConversationInitData(**data)
|
||||
|
||||
async def _save_init_data(self, sid: str, init_data: ConversationInitData):
|
||||
# FIXME: we should not store/restore this data once we have server-side
|
||||
# LLM configs. Should be done by 1/1/2025
|
||||
json_str = json.dumps(init_data.__dict__)
|
||||
await call_sync_from_async(
|
||||
self.file_store.write, get_conversation_init_data_filename(sid), json_str
|
||||
)
|
||||
|
||||
async def initialize_agent(
|
||||
self, conversation_init_data: ConversationInitData | None = None
|
||||
self,
|
||||
settings: Settings,
|
||||
):
|
||||
self.agent_session.event_stream.add_event(
|
||||
AgentStateChangedObservation('', AgentState.LOADING),
|
||||
EventSource.ENVIRONMENT,
|
||||
)
|
||||
if conversation_init_data is None:
|
||||
try:
|
||||
conversation_init_data = await self._restore_init_data(self.sid)
|
||||
except FileNotFoundError:
|
||||
logger.error(f'User settings not found for session {self.sid}')
|
||||
raise RuntimeError('User settings not found')
|
||||
|
||||
agent_cls = conversation_init_data.agent or self.config.default_agent
|
||||
agent_cls = settings.agent or self.config.default_agent
|
||||
self.config.security.confirmation_mode = (
|
||||
self.config.security.confirmation_mode
|
||||
if conversation_init_data.confirmation_mode is None
|
||||
else conversation_init_data.confirmation_mode
|
||||
if settings.confirmation_mode is None
|
||||
else settings.confirmation_mode
|
||||
)
|
||||
self.config.security.security_analyzer = (
|
||||
conversation_init_data.security_analyzer
|
||||
or self.config.security.security_analyzer
|
||||
)
|
||||
max_iterations = (
|
||||
conversation_init_data.max_iterations or self.config.max_iterations
|
||||
settings.security_analyzer or self.config.security.security_analyzer
|
||||
)
|
||||
max_iterations = settings.max_iterations or self.config.max_iterations
|
||||
# override default LLM config
|
||||
|
||||
default_llm_config = self.config.get_llm_config()
|
||||
default_llm_config.model = (
|
||||
conversation_init_data.llm_model or default_llm_config.model
|
||||
)
|
||||
default_llm_config.api_key = (
|
||||
conversation_init_data.llm_api_key or default_llm_config.api_key
|
||||
)
|
||||
default_llm_config.model = settings.llm_model or default_llm_config.model
|
||||
default_llm_config.api_key = settings.llm_api_key or default_llm_config.api_key
|
||||
default_llm_config.base_url = (
|
||||
conversation_init_data.llm_base_url or default_llm_config.base_url
|
||||
settings.llm_base_url or default_llm_config.base_url
|
||||
)
|
||||
await self._save_init_data(self.sid, conversation_init_data)
|
||||
|
||||
# TODO: override other LLM config & agent config groups (#2075)
|
||||
|
||||
@@ -129,6 +97,12 @@ class Session:
|
||||
agent_config = self.config.get_agent_config(agent_cls)
|
||||
agent = Agent.get_cls(agent_cls)(llm, agent_config)
|
||||
|
||||
github_token = None
|
||||
selected_repository = None
|
||||
if isinstance(settings, ConversationInitData):
|
||||
github_token = settings.github_token
|
||||
selected_repository = settings.selected_repository
|
||||
|
||||
try:
|
||||
await self.agent_session.start(
|
||||
runtime_name=self.config.runtime,
|
||||
@@ -138,8 +112,8 @@ class Session:
|
||||
max_budget_per_task=self.config.max_budget_per_task,
|
||||
agent_to_llm_config=self.config.get_agent_to_llm_config_map(),
|
||||
agent_configs=self.config.get_agent_configs(),
|
||||
github_token=conversation_init_data.github_token,
|
||||
selected_repository=conversation_init_data.selected_repository,
|
||||
github_token=github_token,
|
||||
selected_repository=selected_repository,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f'Error creating controller: {e}')
|
||||
|
||||
@@ -9,8 +9,8 @@ IN_MEMORY_FILES: dict = {}
|
||||
class InMemoryFileStore(FileStore):
|
||||
files: dict[str, str]
|
||||
|
||||
def __init__(self):
|
||||
self.files = IN_MEMORY_FILES
|
||||
def __init__(self, files: dict[str, str] = IN_MEMORY_FILES):
|
||||
self.files = files
|
||||
|
||||
def write(self, path: str, contents: str) -> None:
|
||||
self.files[path] = contents
|
||||
|
||||
14
poetry.lock
generated
14
poetry.lock
generated
@@ -553,17 +553,17 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.35.87"
|
||||
version = "1.35.88"
|
||||
description = "The AWS SDK for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "boto3-1.35.87-py3-none-any.whl", hash = "sha256:588ab05e2771c50fca5c242be14e7a25200ffd3dd95c45950ce40993473864c7"},
|
||||
{file = "boto3-1.35.87.tar.gz", hash = "sha256:341c58602889078a4a25dc4331b832b5b600a33acd73471d2532c6f01b16fbb4"},
|
||||
{file = "boto3-1.35.88-py3-none-any.whl", hash = "sha256:7bc9b27ad87607256470c70a86c8b8c319ddd6ecae89cc191687cbf8ccb7b6a6"},
|
||||
{file = "boto3-1.35.88.tar.gz", hash = "sha256:43c6a7a70bb226770a82a601870136e3bb3bf2808f4576ab5b9d7d140dbf1323"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.35.87,<1.36.0"
|
||||
botocore = ">=1.35.88,<1.36.0"
|
||||
jmespath = ">=0.7.1,<2.0.0"
|
||||
s3transfer = ">=0.10.0,<0.11.0"
|
||||
|
||||
@@ -572,13 +572,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.35.87"
|
||||
version = "1.35.88"
|
||||
description = "Low-level, data-driven core of boto 3."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "botocore-1.35.87-py3-none-any.whl", hash = "sha256:81cf84f12030d9ab3829484b04765d5641697ec53c2ac2b3987a99eefe501692"},
|
||||
{file = "botocore-1.35.87.tar.gz", hash = "sha256:3062d073ce4170a994099270f469864169dc1a1b8b3d4a21c14ce0ae995e0f89"},
|
||||
{file = "botocore-1.35.88-py3-none-any.whl", hash = "sha256:e60cc3fbe8d7a10f70e7e852d76be2b29f23ead418a5899d366ea32b1eacb5a5"},
|
||||
{file = "botocore-1.35.88.tar.gz", hash = "sha256:58dcd9a464c354b8c6c25261d8de830d175d9739eae568bf0c52e57116fb03c6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
||||
@@ -100,6 +100,7 @@ reportlab = "*"
|
||||
[tool.coverage.run]
|
||||
concurrency = ["gevent"]
|
||||
|
||||
|
||||
[tool.poetry.group.runtime.dependencies]
|
||||
jupyterlab = "*"
|
||||
notebook = "*"
|
||||
@@ -129,6 +130,7 @@ ignore = ["D1"]
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
|
||||
[tool.poetry.group.evaluation.dependencies]
|
||||
streamlit = "*"
|
||||
whatthepatch = "*"
|
||||
|
||||
@@ -20,6 +20,7 @@ from openhands.llm import LLM
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -168,7 +169,7 @@ async def test_run_controller_with_fatal_error(mock_agent, mock_event_stream):
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_controller_stop_with_stuck():
|
||||
config = AppConfig()
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
file_store = InMemoryFileStore({})
|
||||
event_stream = EventStream(sid='test', file_store=file_store)
|
||||
|
||||
agent = MagicMock(spec=Agent)
|
||||
|
||||
@@ -3,14 +3,37 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
|
||||
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
|
||||
from openhands.agenthub.codeact_agent.function_calling import (
|
||||
_BROWSER_DESCRIPTION,
|
||||
_BROWSER_TOOL_DESCRIPTION,
|
||||
BrowserTool,
|
||||
CmdRunTool,
|
||||
IPythonTool,
|
||||
LLMBasedFileEditTool,
|
||||
StrReplaceEditorTool,
|
||||
WebReadTool,
|
||||
get_tools,
|
||||
response_to_actions,
|
||||
)
|
||||
from openhands.core.config import AgentConfig, LLMConfig
|
||||
from openhands.core.message import TextContent
|
||||
from openhands.core.exceptions import FunctionCallNotExistsError
|
||||
from openhands.core.message import ImageContent, TextContent
|
||||
from openhands.events.action import (
|
||||
AgentFinishAction,
|
||||
CmdRunAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.event import EventSource, FileEditSource, FileReadSource
|
||||
from openhands.events.observation.browse import BrowserOutputObservation
|
||||
from openhands.events.observation.commands import (
|
||||
CmdOutputObservation,
|
||||
IPythonRunCellObservation,
|
||||
)
|
||||
from openhands.events.observation.delegate import AgentDelegateObservation
|
||||
from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.observation.files import FileEditObservation, FileReadObservation
|
||||
from openhands.events.observation.reject import UserRejectObservation
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
|
||||
@@ -102,3 +125,387 @@ def test_unknown_observation_message(agent: CodeActAgent):
|
||||
|
||||
with pytest.raises(ValueError, match='Unknown observation type'):
|
||||
agent.get_observation_message(obs, tool_call_id_to_message={})
|
||||
|
||||
|
||||
def test_file_edit_observation_message(agent: CodeActAgent):
|
||||
agent.config.function_calling = False
|
||||
obs = FileEditObservation(
|
||||
path='/test/file.txt',
|
||||
prev_exist=True,
|
||||
old_content='old content',
|
||||
new_content='new content',
|
||||
content='diff content',
|
||||
impl_source=FileEditSource.LLM_BASED_EDIT,
|
||||
)
|
||||
|
||||
results = agent.get_observation_message(obs, tool_call_id_to_message={})
|
||||
assert len(results) == 1
|
||||
|
||||
result = results[0]
|
||||
assert result is not None
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert '[Existing file /test/file.txt is edited with' in result.content[0].text
|
||||
|
||||
|
||||
def test_file_read_observation_message(agent: CodeActAgent):
|
||||
agent.config.function_calling = False
|
||||
obs = FileReadObservation(
|
||||
path='/test/file.txt',
|
||||
content='File content',
|
||||
impl_source=FileReadSource.DEFAULT,
|
||||
)
|
||||
|
||||
results = agent.get_observation_message(obs, tool_call_id_to_message={})
|
||||
assert len(results) == 1
|
||||
|
||||
result = results[0]
|
||||
assert result is not None
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == 'File content'
|
||||
|
||||
|
||||
def test_browser_output_observation_message(agent: CodeActAgent):
|
||||
agent.config.function_calling = False
|
||||
obs = BrowserOutputObservation(
|
||||
url='http://example.com',
|
||||
trigger_by_action='browse',
|
||||
screenshot='',
|
||||
content='Page loaded',
|
||||
error=False,
|
||||
)
|
||||
|
||||
results = agent.get_observation_message(obs, tool_call_id_to_message={})
|
||||
assert len(results) == 1
|
||||
|
||||
result = results[0]
|
||||
assert result is not None
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert '[Current URL: http://example.com]' in result.content[0].text
|
||||
|
||||
|
||||
def test_user_reject_observation_message(agent: CodeActAgent):
|
||||
agent.config.function_calling = False
|
||||
obs = UserRejectObservation('Action rejected')
|
||||
|
||||
results = agent.get_observation_message(obs, tool_call_id_to_message={})
|
||||
assert len(results) == 1
|
||||
|
||||
result = results[0]
|
||||
assert result is not None
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert 'Action rejected' in result.content[0].text
|
||||
assert '[Last action has been rejected by the user]' in result.content[0].text
|
||||
|
||||
|
||||
def test_function_calling_observation_message(agent: CodeActAgent):
|
||||
agent.config.function_calling = True
|
||||
mock_response = {
|
||||
'id': 'mock_id',
|
||||
'total_calls_in_response': 1,
|
||||
'choices': [{'message': {'content': 'Task completed'}}],
|
||||
}
|
||||
obs = CmdOutputObservation(
|
||||
command='echo hello',
|
||||
content='Command output',
|
||||
command_id=1,
|
||||
exit_code=0,
|
||||
)
|
||||
obs.tool_call_metadata = ToolCallMetadata(
|
||||
tool_call_id='123',
|
||||
function_name='execute_bash',
|
||||
model_response=mock_response,
|
||||
total_calls_in_response=1,
|
||||
)
|
||||
|
||||
results = agent.get_observation_message(obs, tool_call_id_to_message={})
|
||||
assert len(results) == 0 # No direct message when using function calling
|
||||
|
||||
|
||||
def test_message_action_with_image(agent: CodeActAgent):
|
||||
action = MessageAction(
|
||||
content='Message with image',
|
||||
image_urls=['http://example.com/image.jpg'],
|
||||
)
|
||||
action._source = EventSource.AGENT
|
||||
|
||||
results = agent.get_action_message(action, {})
|
||||
assert len(results) == 1
|
||||
|
||||
result = results[0]
|
||||
assert result is not None
|
||||
assert result.role == 'assistant'
|
||||
assert len(result.content) == 2
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert isinstance(result.content[1], ImageContent)
|
||||
assert result.content[0].text == 'Message with image'
|
||||
assert result.content[1].image_urls == ['http://example.com/image.jpg']
|
||||
|
||||
|
||||
def test_user_cmd_action_message(agent: CodeActAgent):
|
||||
action = CmdRunAction(command='ls -l')
|
||||
action._source = EventSource.USER
|
||||
|
||||
results = agent.get_action_message(action, {})
|
||||
assert len(results) == 1
|
||||
|
||||
result = results[0]
|
||||
assert result is not None
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert 'User executed the command' in result.content[0].text
|
||||
assert 'ls -l' in result.content[0].text
|
||||
|
||||
|
||||
def test_agent_finish_action_with_tool_metadata(agent: CodeActAgent):
|
||||
mock_response = {
|
||||
'id': 'mock_id',
|
||||
'total_calls_in_response': 1,
|
||||
'choices': [{'message': {'content': 'Task completed'}}],
|
||||
}
|
||||
|
||||
action = AgentFinishAction(thought='Initial thought')
|
||||
action._source = EventSource.AGENT
|
||||
action.tool_call_metadata = ToolCallMetadata(
|
||||
tool_call_id='123',
|
||||
function_name='finish',
|
||||
model_response=mock_response,
|
||||
total_calls_in_response=1,
|
||||
)
|
||||
|
||||
results = agent.get_action_message(action, {})
|
||||
assert len(results) == 1
|
||||
|
||||
result = results[0]
|
||||
assert result is not None
|
||||
assert result.role == 'assistant'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert 'Initial thought\nTask completed' in result.content[0].text
|
||||
|
||||
|
||||
def test_reset(agent: CodeActAgent):
|
||||
# Add some state
|
||||
action = MessageAction(content='test')
|
||||
action._source = EventSource.AGENT
|
||||
agent.pending_actions.append(action)
|
||||
|
||||
# Reset
|
||||
agent.reset()
|
||||
|
||||
# Verify state is cleared
|
||||
assert len(agent.pending_actions) == 0
|
||||
|
||||
|
||||
def test_step_with_pending_actions(agent: CodeActAgent):
|
||||
# Add a pending action
|
||||
pending_action = MessageAction(content='test')
|
||||
pending_action._source = EventSource.AGENT
|
||||
agent.pending_actions.append(pending_action)
|
||||
|
||||
# Step should return the pending action
|
||||
result = agent.step(Mock())
|
||||
assert result == pending_action
|
||||
assert len(agent.pending_actions) == 0
|
||||
|
||||
|
||||
def test_get_tools_default():
|
||||
tools = get_tools(
|
||||
codeact_enable_jupyter=True,
|
||||
codeact_enable_llm_editor=True,
|
||||
codeact_enable_browsing=True,
|
||||
)
|
||||
assert len(tools) > 0
|
||||
|
||||
# Check required tools are present
|
||||
tool_names = [tool['function']['name'] for tool in tools]
|
||||
assert 'execute_bash' in tool_names
|
||||
assert 'execute_ipython_cell' in tool_names
|
||||
assert 'edit_file' in tool_names
|
||||
assert 'web_read' in tool_names
|
||||
|
||||
|
||||
def test_get_tools_with_options():
|
||||
# Test with all options enabled
|
||||
tools = get_tools(
|
||||
codeact_enable_browsing=True,
|
||||
codeact_enable_jupyter=True,
|
||||
codeact_enable_llm_editor=True,
|
||||
)
|
||||
tool_names = [tool['function']['name'] for tool in tools]
|
||||
assert 'browser' in tool_names
|
||||
assert 'execute_ipython_cell' in tool_names
|
||||
assert 'edit_file' in tool_names
|
||||
|
||||
# Test with all options disabled
|
||||
tools = get_tools(
|
||||
codeact_enable_browsing=False,
|
||||
codeact_enable_jupyter=False,
|
||||
codeact_enable_llm_editor=False,
|
||||
)
|
||||
tool_names = [tool['function']['name'] for tool in tools]
|
||||
assert 'browser' not in tool_names
|
||||
assert 'execute_ipython_cell' not in tool_names
|
||||
assert 'edit_file' not in tool_names
|
||||
|
||||
|
||||
def test_cmd_run_tool():
|
||||
assert CmdRunTool['type'] == 'function'
|
||||
assert CmdRunTool['function']['name'] == 'execute_bash'
|
||||
assert 'command' in CmdRunTool['function']['parameters']['properties']
|
||||
assert CmdRunTool['function']['parameters']['required'] == ['command']
|
||||
|
||||
|
||||
def test_ipython_tool():
|
||||
assert IPythonTool['type'] == 'function'
|
||||
assert IPythonTool['function']['name'] == 'execute_ipython_cell'
|
||||
assert 'code' in IPythonTool['function']['parameters']['properties']
|
||||
assert IPythonTool['function']['parameters']['required'] == ['code']
|
||||
|
||||
|
||||
def test_llm_based_file_edit_tool():
|
||||
assert LLMBasedFileEditTool['type'] == 'function'
|
||||
assert LLMBasedFileEditTool['function']['name'] == 'edit_file'
|
||||
|
||||
properties = LLMBasedFileEditTool['function']['parameters']['properties']
|
||||
assert 'path' in properties
|
||||
assert 'content' in properties
|
||||
assert 'start' in properties
|
||||
assert 'end' in properties
|
||||
|
||||
assert LLMBasedFileEditTool['function']['parameters']['required'] == [
|
||||
'path',
|
||||
'content',
|
||||
]
|
||||
|
||||
|
||||
def test_str_replace_editor_tool():
|
||||
assert StrReplaceEditorTool['type'] == 'function'
|
||||
assert StrReplaceEditorTool['function']['name'] == 'str_replace_editor'
|
||||
|
||||
properties = StrReplaceEditorTool['function']['parameters']['properties']
|
||||
assert 'command' in properties
|
||||
assert 'path' in properties
|
||||
assert 'file_text' in properties
|
||||
assert 'old_str' in properties
|
||||
assert 'new_str' in properties
|
||||
assert 'insert_line' in properties
|
||||
assert 'view_range' in properties
|
||||
|
||||
assert StrReplaceEditorTool['function']['parameters']['required'] == [
|
||||
'command',
|
||||
'path',
|
||||
]
|
||||
|
||||
|
||||
def test_web_read_tool():
|
||||
assert WebReadTool['type'] == 'function'
|
||||
assert WebReadTool['function']['name'] == 'web_read'
|
||||
assert 'url' in WebReadTool['function']['parameters']['properties']
|
||||
assert WebReadTool['function']['parameters']['required'] == ['url']
|
||||
|
||||
|
||||
def test_browser_tool():
|
||||
assert BrowserTool['type'] == 'function'
|
||||
assert BrowserTool['function']['name'] == 'browser'
|
||||
assert 'code' in BrowserTool['function']['parameters']['properties']
|
||||
assert BrowserTool['function']['parameters']['required'] == ['code']
|
||||
# Check that the description includes all the functions
|
||||
description = _BROWSER_TOOL_DESCRIPTION
|
||||
assert 'goto(' in description
|
||||
assert 'go_back()' in description
|
||||
assert 'go_forward()' in description
|
||||
assert 'noop(' in description
|
||||
assert 'scroll(' in description
|
||||
assert 'fill(' in description
|
||||
assert 'select_option(' in description
|
||||
assert 'click(' in description
|
||||
assert 'dblclick(' in description
|
||||
assert 'hover(' in description
|
||||
assert 'press(' in description
|
||||
assert 'focus(' in description
|
||||
assert 'clear(' in description
|
||||
assert 'drag_and_drop(' in description
|
||||
assert 'upload_file(' in description
|
||||
|
||||
# Test BrowserTool definition
|
||||
assert BrowserTool['type'] == 'function'
|
||||
assert BrowserTool['function']['name'] == 'browser'
|
||||
assert BrowserTool['function']['description'] == _BROWSER_DESCRIPTION
|
||||
assert BrowserTool['function']['parameters']['type'] == 'object'
|
||||
assert 'code' in BrowserTool['function']['parameters']['properties']
|
||||
assert BrowserTool['function']['parameters']['required'] == ['code']
|
||||
assert (
|
||||
BrowserTool['function']['parameters']['properties']['code']['type'] == 'string'
|
||||
)
|
||||
assert 'description' in BrowserTool['function']['parameters']['properties']['code']
|
||||
|
||||
|
||||
def test_mock_function_calling():
|
||||
# Test mock function calling when LLM doesn't support it
|
||||
llm = Mock()
|
||||
llm.is_function_calling_active = lambda: False
|
||||
config = AgentConfig()
|
||||
config.use_microagents = False
|
||||
agent = CodeActAgent(llm=llm, config=config)
|
||||
assert agent.mock_function_calling is True
|
||||
|
||||
|
||||
def test_response_to_actions_invalid_tool():
|
||||
# Test response with invalid tool call
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.content = 'Invalid tool'
|
||||
mock_response.choices[0].message.tool_calls = [Mock()]
|
||||
mock_response.choices[0].message.tool_calls[0].id = 'tool_call_10'
|
||||
mock_response.choices[0].message.tool_calls[0].function = Mock()
|
||||
mock_response.choices[0].message.tool_calls[0].function.name = 'invalid_tool'
|
||||
mock_response.choices[0].message.tool_calls[0].function.arguments = '{}'
|
||||
|
||||
with pytest.raises(FunctionCallNotExistsError):
|
||||
response_to_actions(mock_response)
|
||||
|
||||
|
||||
def test_step_with_no_pending_actions():
|
||||
# Mock the LLM response
|
||||
mock_response = Mock()
|
||||
mock_response.id = 'mock_id'
|
||||
mock_response.total_calls_in_response = 1
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.content = 'Task completed'
|
||||
mock_response.choices[0].message.tool_calls = []
|
||||
|
||||
llm = Mock()
|
||||
llm.completion = Mock(return_value=mock_response)
|
||||
llm.is_function_calling_active = Mock(return_value=True) # Enable function calling
|
||||
llm.is_caching_prompt_active = Mock(return_value=False)
|
||||
|
||||
# Create agent with mocked LLM
|
||||
config = AgentConfig()
|
||||
config.use_microagents = False
|
||||
agent = CodeActAgent(llm=llm, config=config)
|
||||
|
||||
# Test step with no pending actions
|
||||
state = Mock()
|
||||
state.history = []
|
||||
state.latest_user_message = None
|
||||
state.latest_user_message_id = None
|
||||
state.latest_user_message_timestamp = None
|
||||
state.latest_user_message_cause = None
|
||||
state.latest_user_message_timeout = None
|
||||
state.latest_user_message_llm_metrics = None
|
||||
state.latest_user_message_tool_call_metadata = None
|
||||
|
||||
action = agent.step(state)
|
||||
assert isinstance(action, MessageAction)
|
||||
assert action.content == 'Task completed'
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -35,44 +36,56 @@ def get_mock_sio(get_message: GetMessageMock | None = None):
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_not_running_in_cluster():
|
||||
sio = get_mock_sio()
|
||||
id = uuid4()
|
||||
with (
|
||||
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
|
||||
patch('openhands.server.session.manager.uuid4', MagicMock(return_value=id)),
|
||||
):
|
||||
async with SessionManager(
|
||||
sio, AppConfig(), InMemoryFileStore()
|
||||
) as session_manager:
|
||||
result = await session_manager._is_agent_loop_running_in_cluster(
|
||||
result = await session_manager.is_agent_loop_running_in_cluster(
|
||||
'non-existant-session'
|
||||
)
|
||||
assert result is False
|
||||
assert sio.manager.redis.publish.await_count == 1
|
||||
sio.manager.redis.publish.assert_called_once_with(
|
||||
'oh_event',
|
||||
'{"sid": "non-existant-session", "message_type": "is_session_running"}',
|
||||
'{"request_id": "'
|
||||
+ str(id)
|
||||
+ '", "sids": ["non-existant-session"], "message_type": "is_session_running"}',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_is_running_in_cluster():
|
||||
id = uuid4()
|
||||
sio = get_mock_sio(
|
||||
GetMessageMock(
|
||||
{'sid': 'existing-session', 'message_type': 'session_is_running'}
|
||||
{
|
||||
'request_id': str(id),
|
||||
'sids': ['existing-session'],
|
||||
'message_type': 'session_is_running',
|
||||
}
|
||||
)
|
||||
)
|
||||
with (
|
||||
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.1),
|
||||
patch('openhands.server.session.manager.uuid4', MagicMock(return_value=id)),
|
||||
):
|
||||
async with SessionManager(
|
||||
sio, AppConfig(), InMemoryFileStore()
|
||||
) as session_manager:
|
||||
result = await session_manager._is_agent_loop_running_in_cluster(
|
||||
result = await session_manager.is_agent_loop_running_in_cluster(
|
||||
'existing-session'
|
||||
)
|
||||
assert result is True
|
||||
assert sio.manager.redis.publish.await_count == 1
|
||||
sio.manager.redis.publish.assert_called_once_with(
|
||||
'oh_event',
|
||||
'{"sid": "existing-session", "message_type": "is_session_running"}',
|
||||
'{"request_id": "'
|
||||
+ str(id)
|
||||
+ '", "sids": ["existing-session"], "message_type": "is_session_running"}',
|
||||
)
|
||||
|
||||
|
||||
@@ -93,7 +106,7 @@ async def test_init_new_local_session():
|
||||
AsyncMock(),
|
||||
),
|
||||
patch(
|
||||
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
|
||||
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
|
||||
is_agent_loop_running_in_cluster_mock,
|
||||
),
|
||||
):
|
||||
@@ -103,7 +116,9 @@ async def test_init_new_local_session():
|
||||
await session_manager.maybe_start_agent_loop(
|
||||
'new-session-id', ConversationInitData()
|
||||
)
|
||||
await session_manager.join_conversation('new-session-id', 'new-session-id')
|
||||
await session_manager.join_conversation(
|
||||
'new-session-id', 'new-session-id', ConversationInitData()
|
||||
)
|
||||
assert session_instance.initialize_agent.call_count == 1
|
||||
assert sio.enter_room.await_count == 1
|
||||
|
||||
@@ -125,7 +140,7 @@ async def test_join_local_session():
|
||||
AsyncMock(),
|
||||
),
|
||||
patch(
|
||||
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
|
||||
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
|
||||
is_agent_loop_running_in_cluster_mock,
|
||||
),
|
||||
):
|
||||
@@ -135,8 +150,12 @@ async def test_join_local_session():
|
||||
await session_manager.maybe_start_agent_loop(
|
||||
'new-session-id', ConversationInitData()
|
||||
)
|
||||
await session_manager.join_conversation('new-session-id', 'new-session-id')
|
||||
await session_manager.join_conversation('new-session-id', 'new-session-id')
|
||||
await session_manager.join_conversation(
|
||||
'new-session-id', 'new-session-id', ConversationInitData()
|
||||
)
|
||||
await session_manager.join_conversation(
|
||||
'new-session-id', 'new-session-id', ConversationInitData()
|
||||
)
|
||||
assert session_instance.initialize_agent.call_count == 1
|
||||
assert sio.enter_room.await_count == 2
|
||||
|
||||
@@ -158,14 +177,16 @@ async def test_join_cluster_session():
|
||||
AsyncMock(),
|
||||
),
|
||||
patch(
|
||||
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
|
||||
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
|
||||
is_agent_loop_running_in_cluster_mock,
|
||||
),
|
||||
):
|
||||
async with SessionManager(
|
||||
sio, AppConfig(), InMemoryFileStore()
|
||||
) as session_manager:
|
||||
await session_manager.join_conversation('new-session-id', 'new-session-id')
|
||||
await session_manager.join_conversation(
|
||||
'new-session-id', 'new-session-id', ConversationInitData()
|
||||
)
|
||||
assert session_instance.initialize_agent.call_count == 0
|
||||
assert sio.enter_room.await_count == 1
|
||||
|
||||
@@ -187,7 +208,7 @@ async def test_add_to_local_event_stream():
|
||||
AsyncMock(),
|
||||
),
|
||||
patch(
|
||||
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
|
||||
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
|
||||
is_agent_loop_running_in_cluster_mock,
|
||||
),
|
||||
):
|
||||
@@ -197,7 +218,9 @@ async def test_add_to_local_event_stream():
|
||||
await session_manager.maybe_start_agent_loop(
|
||||
'new-session-id', ConversationInitData()
|
||||
)
|
||||
await session_manager.join_conversation('new-session-id', 'connection-id')
|
||||
await session_manager.join_conversation(
|
||||
'new-session-id', 'connection-id', ConversationInitData()
|
||||
)
|
||||
await session_manager.send_to_event_stream(
|
||||
'connection-id', {'event_type': 'some_event'}
|
||||
)
|
||||
@@ -221,14 +244,16 @@ async def test_add_to_cluster_event_stream():
|
||||
AsyncMock(),
|
||||
),
|
||||
patch(
|
||||
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
|
||||
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
|
||||
is_agent_loop_running_in_cluster_mock,
|
||||
),
|
||||
):
|
||||
async with SessionManager(
|
||||
sio, AppConfig(), InMemoryFileStore()
|
||||
) as session_manager:
|
||||
await session_manager.join_conversation('new-session-id', 'connection-id')
|
||||
await session_manager.join_conversation(
|
||||
'new-session-id', 'connection-id', ConversationInitData()
|
||||
)
|
||||
await session_manager.send_to_event_stream(
|
||||
'connection-id', {'event_type': 'some_event'}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user