mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
21 Commits
openhands-
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76c8ebd17 | ||
|
|
ebb2d86ce3 | ||
|
|
6a4442e590 | ||
|
|
157ff4a4b9 | ||
|
|
cc928e6d3f | ||
|
|
6a75800e1b | ||
|
|
c9cecbc461 | ||
|
|
97b1867ea1 | ||
|
|
9bdc1df2df | ||
|
|
9d984aaa30 | ||
|
|
5ed80b5c32 | ||
|
|
df82202178 | ||
|
|
500598666e | ||
|
|
69a9080480 | ||
|
|
b72f50cc4a | ||
|
|
f1a8be3817 | ||
|
|
b34209c9a0 | ||
|
|
a021045dce | ||
|
|
ad45f8dab0 | ||
|
|
3bf5956493 | ||
|
|
d86b536d2f |
@@ -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 \
|
||||
|
||||
@@ -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()
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -3,11 +3,17 @@ Given the following issue description and the last message from an AI agent atte
|
||||
Issue description:
|
||||
{{ issue_context }}
|
||||
|
||||
Last message from AI agent:
|
||||
Last message from AI agent (including any patch content):
|
||||
{{ last_message }}
|
||||
|
||||
(1) has the issue been successfully resolved?
|
||||
(2) If the issue has been resolved, please provide an explanation of what was done in the PR that can be sent to a human reviewer on github. If the issue has not been resolved, please provide an explanation of why.
|
||||
Please analyze:
|
||||
1. Has the issue been successfully resolved? Look for concrete evidence such as:
|
||||
- Patch content showing actual code changes
|
||||
- Clear description of what was fixed
|
||||
- Specific files that were modified
|
||||
2. If patch content is present, carefully examine the changes to verify they address the issue
|
||||
3. If the issue has been resolved, provide an explanation of what was done in the PR that can be sent to a human reviewer on github
|
||||
4. If the issue has not been resolved, explain why
|
||||
|
||||
Answer in exactly the format below, with only true or false for success, and an explanation of the result.
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
87
tests/test_issue_success_guess.py
Normal file
87
tests/test_issue_success_guess.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import os
|
||||
import pytest
|
||||
from openhands.resolver.issue_definitions import IssueHandler
|
||||
from openhands.resolver.github_issue import GithubIssue
|
||||
from openhands.events.event import Event
|
||||
from openhands.core.config import LLMConfig
|
||||
|
||||
@pytest.fixture
|
||||
def issue_handler():
|
||||
return IssueHandler(
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
token="test-token",
|
||||
llm_config=LLMConfig(model="gpt-4", api_key="test-key")
|
||||
)
|
||||
|
||||
def test_guess_success_with_patch_content(issue_handler, mocker):
|
||||
# Mock the issue
|
||||
issue = GithubIssue(
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=1,
|
||||
title="Test Issue",
|
||||
body="Fix the bug in the code",
|
||||
thread_comments=None,
|
||||
review_comments=None
|
||||
)
|
||||
|
||||
# Mock the history with patch content
|
||||
event = Event()
|
||||
event._message = "All done! I've fixed the issue by making the following changes:\n\nPatch content:\n```diff\n--- a/src/file.py\n+++ b/src/file.py\n@@ -10,7 +10,7 @@\n- buggy_code()\n+ fixed_code()\n```"
|
||||
history = [event]
|
||||
|
||||
# Mock LLM response
|
||||
mock_response = mocker.MagicMock()
|
||||
mock_response.choices = [
|
||||
mocker.MagicMock(
|
||||
message=mocker.MagicMock(
|
||||
content="""--- success
|
||||
true
|
||||
--- explanation
|
||||
The issue has been resolved. The patch shows that the buggy code was replaced with fixed code."""
|
||||
)
|
||||
)
|
||||
]
|
||||
mocker.patch.object(issue_handler.llm, '_completion', return_value=mock_response)
|
||||
|
||||
# Test the function
|
||||
success, _, explanation = issue_handler.guess_success(issue, history)
|
||||
assert success is True
|
||||
assert "patch shows" in explanation.lower()
|
||||
|
||||
def test_guess_success_without_patch_content(issue_handler, mocker):
|
||||
# Mock the issue
|
||||
issue = GithubIssue(
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=1,
|
||||
title="Test Issue",
|
||||
body="Fix the bug in the code",
|
||||
thread_comments=None,
|
||||
review_comments=None
|
||||
)
|
||||
|
||||
# Mock the history without patch content
|
||||
event = Event()
|
||||
event._message = "All done!"
|
||||
history = [event]
|
||||
|
||||
# Mock LLM response
|
||||
mock_response = mocker.MagicMock()
|
||||
mock_response.choices = [
|
||||
mocker.MagicMock(
|
||||
message=mocker.MagicMock(
|
||||
content="""--- success
|
||||
false
|
||||
--- explanation
|
||||
Cannot verify the resolution as no patch content is provided."""
|
||||
)
|
||||
)
|
||||
]
|
||||
mocker.patch.object(issue_handler.llm, '_completion', return_value=mock_response)
|
||||
|
||||
# Test the function
|
||||
success, _, explanation = issue_handler.guess_success(issue, history)
|
||||
assert success is False
|
||||
assert "no patch content" in explanation.lower()
|
||||
@@ -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)
|
||||
|
||||
@@ -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