mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
7 Commits
0.22.0
...
eval/visua
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
532a284d5c | ||
|
|
43f6104967 | ||
|
|
e249b920ff | ||
|
|
d920a69f69 | ||
|
|
a8ce888981 | ||
|
|
e22ddc0dd6 | ||
|
|
c370912f12 |
1
.github/ISSUE_TEMPLATE/bug_template.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_template.yml
vendored
@@ -30,7 +30,6 @@ body:
|
||||
description: How are you running OpenHands?
|
||||
options:
|
||||
- Docker command in README
|
||||
- GitHub resolver
|
||||
- Development workflow
|
||||
- app.all-hands.dev
|
||||
- Other
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -176,6 +176,7 @@ evaluation/gorilla/data
|
||||
evaluation/toolqa/data
|
||||
evaluation/scienceagentbench/benchmark
|
||||
evaluation/commit0_bench/repos
|
||||
evaluation/visualcodebench/
|
||||
|
||||
# openhands resolver
|
||||
output/
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="https://codecov.io/github/All-Hands-AI/OpenHands?branch=main"><img alt="CodeCov" src="https://img.shields.io/codecov/c/github/All-Hands-AI/OpenHands?style=for-the-badge&color=blue"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
|
||||
<br/>
|
||||
@@ -96,7 +96,7 @@ troubleshooting resources, and advanced configuration options.
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
|
||||
through Slack, so this is the best place to start, but we also are happy to have you contact us on Discord or Github:
|
||||
|
||||
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Discord server](https://discord.gg/ESHStjSjD4) - This is a community-run server for general discussion, questions, and feedback.
|
||||
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
|
||||
|
||||
|
||||
@@ -95,3 +95,7 @@ sandbox_user_id="1001"
|
||||
### Erreurs de port d'utilisation
|
||||
|
||||
Si vous voyez un message d'erreur indiquant que le port est utilisé ou indisponible, essayez de supprimer toutes les containers docker en cours d'exécution (exécutez `docker ps` et `docker rm` des containers concernés) puis ré-exécutez ```make run```
|
||||
|
||||
## Discuter
|
||||
|
||||
Pour d'autres problèmes ou questions rejoignez le [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) ou le [Discord](https://discord.gg/ESHStjSjD4) et demandez!
|
||||
|
||||
@@ -42,7 +42,7 @@ Explorez le code source d'OpenHands sur [GitHub](https://github.com/All-Hands-AI
|
||||
/>
|
||||
</a>
|
||||
<br></br>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw">
|
||||
<img
|
||||
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
|
||||
alt="Join our Slack community"
|
||||
|
||||
@@ -96,3 +96,7 @@ sandbox_user_id="1001"
|
||||
### 端口使用错误
|
||||
|
||||
如果您遇到端口被占用或不可用的错误提示,可以尝试先用`docker ps`命令列出所有运行中的 Docker 容器,然后使用`docker rm`命令删除相关容器,最后再重新执行```make run```命令。
|
||||
|
||||
## 讨论
|
||||
|
||||
对于其他问题或疑问,请加入 [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) 或 [Discord](https://discord.gg/ESHStjSjD4) 提问!
|
||||
|
||||
@@ -42,7 +42,7 @@ OpenHands 是一个**自主 AI 软件工程师**,能够执行复杂的工程
|
||||
/>
|
||||
</a>
|
||||
<br></br>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw">
|
||||
<img
|
||||
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
|
||||
alt="Join our Slack community"
|
||||
|
||||
2077
docs/package-lock.json
generated
2077
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,8 @@
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-use": "^17.6.0"
|
||||
},
|
||||
@@ -31,7 +31,7 @@
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.7.3"
|
||||
"typescript": "~5.7.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -8,7 +8,7 @@ function CustomFooter() {
|
||||
<footer className="custom-footer">
|
||||
<div className="footer-content">
|
||||
<div className="footer-icons">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg" target="_blank" rel="noopener noreferrer">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw" target="_blank" rel="noopener noreferrer">
|
||||
<FaSlack />
|
||||
</a>
|
||||
<a href="https://discord.gg/ESHStjSjD4" target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -23,7 +23,7 @@ export function HomepageHeader() {
|
||||
<a href="https://codecov.io/github/All-Hands-AI/OpenHands?branch=main"><img alt="CodeCov" src="https://img.shields.io/codecov/c/github/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" /></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License" /></a>
|
||||
<br/>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community" /></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community" /></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community" /></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits" /></a>
|
||||
<br/>
|
||||
|
||||
@@ -67,11 +67,11 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
|
||||
'<uploaded_files>\n'
|
||||
f'/workspace/{workspace_dir_name}\n'
|
||||
'</uploaded_files>\n'
|
||||
f"I've uploaded a python code repository in the directory {workspace_dir_name}. Consider the following issue description:\n\n"
|
||||
f'<issue_description>\n'
|
||||
f"I've uploaded a python code repository in the directory {workspace_dir_name}. Consider the following PR description:\n\n"
|
||||
f'<pr_description>\n'
|
||||
f'{instance.problem_statement}\n'
|
||||
'</issue_description>\n\n'
|
||||
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <issue_description> are met?\n'
|
||||
'</pr_description>\n\n'
|
||||
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <pr_description> are met?\n'
|
||||
"I've already taken care of all changes to any of the test files described in the <pr_description>. This means you DON'T have to modify the testing logic or any of the tests in any way!\n"
|
||||
'Your task is to make the minimal changes to non-tests files in the /workspace directory to ensure the <pr_description> is satisfied.\n'
|
||||
'Follow these steps to resolve the issue:\n'
|
||||
|
||||
@@ -15,25 +15,11 @@ parser.add_argument(
|
||||
action='store_true',
|
||||
help='Show visualization paths for failed instances',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--only-x-instances',
|
||||
action='store_true',
|
||||
help='Only show instances that are ran by X',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
df1 = pd.read_json(args.input_file_1, orient='records', lines=True)
|
||||
df2 = pd.read_json(args.input_file_2, orient='records', lines=True)
|
||||
|
||||
if args.only_x_instances:
|
||||
instance_ids_1 = set(df1['instance_id'].tolist())
|
||||
print(
|
||||
f'Before removing instances not in X={args.input_file_1}: Y={df2.shape[0]} instances'
|
||||
)
|
||||
df2 = df2[df2['instance_id'].isin(instance_ids_1)]
|
||||
print(
|
||||
f'After removing instances not in X={args.input_file_1}: Y={df2.shape[0]} instances'
|
||||
)
|
||||
|
||||
# Get the intersection of the instance_ids
|
||||
df = pd.merge(df1, df2, on='instance_id', how='inner')
|
||||
@@ -100,7 +86,7 @@ repo_diffs = []
|
||||
for repo in all_repos:
|
||||
x_count = len(x_only_by_repo.get(repo, []))
|
||||
y_count = len(y_only_by_repo.get(repo, []))
|
||||
diff = y_count - x_count
|
||||
diff = abs(x_count - y_count)
|
||||
repo_diffs.append((repo, diff))
|
||||
|
||||
# Sort by diff (descending) and then by repo name
|
||||
@@ -120,13 +106,7 @@ for repo, diff in repo_diffs:
|
||||
repo_color = 'red' if is_significant else 'yellow'
|
||||
|
||||
print(f"\n{colored(repo, repo_color, attrs=['bold'])}:")
|
||||
print(
|
||||
colored(
|
||||
f'Difference: {diff} instances! (Larger diff = Y better)',
|
||||
repo_color,
|
||||
attrs=['bold'],
|
||||
)
|
||||
)
|
||||
print(colored(f'Difference: {diff} instances!', repo_color, attrs=['bold']))
|
||||
print(colored(f'X resolved but Y failed: ({len(x_instances)} instances)', 'green'))
|
||||
if x_instances:
|
||||
print(' ' + str(x_instances))
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
import pandas as pd
|
||||
from tqdm import tqdm
|
||||
@@ -21,7 +20,6 @@ output_md_folder = args.oh_output_file.replace('.jsonl', '.viz')
|
||||
print(f'Converting {args.oh_output_file} to markdown files in {output_md_folder}')
|
||||
|
||||
oh_format = pd.read_json(args.oh_output_file, orient='records', lines=True)
|
||||
output_dir = os.path.dirname(args.oh_output_file)
|
||||
|
||||
swebench_eval_file = args.oh_output_file.replace('.jsonl', '.swebench_eval.jsonl')
|
||||
if os.path.exists(swebench_eval_file):
|
||||
@@ -59,172 +57,22 @@ def convert_history_to_str(history):
|
||||
return ret
|
||||
|
||||
|
||||
# Load trajectories for resolved instances
|
||||
def load_completions(instance_id: str):
|
||||
global output_dir
|
||||
glob_path = os.path.join(output_dir, 'llm_completions', instance_id, '*.json')
|
||||
files = sorted(glob(glob_path)) # this is ascending order
|
||||
# pick the last file (last turn)
|
||||
try:
|
||||
file_path = files[-1]
|
||||
except IndexError:
|
||||
# print(f'No files found for instance {instance_id}: files={files}')
|
||||
return None
|
||||
with open(file_path, 'r') as f:
|
||||
result = json.load(f)
|
||||
# create messages
|
||||
messages = result['messages']
|
||||
messages.append(result['response']['choices'][0]['message'])
|
||||
tools = result['kwargs']['tools']
|
||||
return {
|
||||
'messages': messages,
|
||||
'tools': tools,
|
||||
}
|
||||
|
||||
|
||||
def _convert_content(content) -> str:
|
||||
ret = ''
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
assert item['type'] == 'text', 'Only text is supported for now'
|
||||
ret += f'{item["text"]}\n'
|
||||
else:
|
||||
assert isinstance(content, str), 'Only str is supported for now'
|
||||
ret = content
|
||||
return ret
|
||||
|
||||
|
||||
def convert_tool_call_to_string(tool_call: dict) -> str:
|
||||
"""Convert tool call to content in string format."""
|
||||
if 'function' not in tool_call:
|
||||
raise ValueError("Tool call must contain 'function' key.")
|
||||
if 'id' not in tool_call:
|
||||
raise ValueError("Tool call must contain 'id' key.")
|
||||
if 'type' not in tool_call:
|
||||
raise ValueError("Tool call must contain 'type' key.")
|
||||
if tool_call['type'] != 'function':
|
||||
raise ValueError("Tool call type must be 'function'.")
|
||||
|
||||
ret = f"<function={tool_call['function']['name']}>\n"
|
||||
try:
|
||||
args = json.loads(tool_call['function']['arguments'])
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(
|
||||
f"Failed to parse arguments as JSON. Arguments: {tool_call['function']['arguments']}"
|
||||
) from e
|
||||
for param_name, param_value in args.items():
|
||||
is_multiline = isinstance(param_value, str) and '\n' in param_value
|
||||
ret += f'<parameter={param_name}>'
|
||||
if is_multiline:
|
||||
ret += '\n'
|
||||
ret += f'{param_value}'
|
||||
if is_multiline:
|
||||
ret += '\n'
|
||||
ret += '</parameter>\n'
|
||||
ret += '</function>'
|
||||
return ret
|
||||
|
||||
|
||||
def format_traj(traj, first_n_turns=None, last_n_turns=None) -> str:
|
||||
output = ''
|
||||
system_message = None
|
||||
|
||||
# Handle system message if present
|
||||
if traj[0]['role'] == 'system':
|
||||
system_message = traj[0]
|
||||
traj = traj[1:]
|
||||
content = _convert_content(system_message['content'])
|
||||
output += "*** System Message that describes the assistant's behavior ***\n"
|
||||
output += f'{content}\n'
|
||||
|
||||
# Merge consecutive user messages first
|
||||
merged_traj = []
|
||||
current_messages = []
|
||||
|
||||
n_turns = len(traj)
|
||||
for i, message in enumerate(traj):
|
||||
# Skip this message if...
|
||||
if (
|
||||
# Case 1: first_n_turns specified and we're past it
|
||||
(first_n_turns is not None and i >= first_n_turns and last_n_turns is None)
|
||||
or
|
||||
# Case 2: last_n_turns specified and we're before it
|
||||
(
|
||||
last_n_turns is not None
|
||||
and i < n_turns - last_n_turns
|
||||
and first_n_turns is None
|
||||
)
|
||||
or
|
||||
# Case 3: both specified and we're in the middle section
|
||||
(
|
||||
first_n_turns is not None
|
||||
and last_n_turns is not None
|
||||
and i >= first_n_turns
|
||||
and i < n_turns - last_n_turns
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
if message['role'] == 'user':
|
||||
current_messages.append(message)
|
||||
else:
|
||||
if current_messages:
|
||||
# Merge all accumulated user messages into one
|
||||
merged_content = '\n'.join(
|
||||
_convert_content(msg['content']) for msg in current_messages
|
||||
)
|
||||
merged_traj.append({'role': 'user', 'content': merged_content})
|
||||
current_messages = []
|
||||
merged_traj.append(message)
|
||||
|
||||
# Don't forget to handle any remaining user messages
|
||||
if current_messages:
|
||||
merged_content = '\n'.join(
|
||||
_convert_content(msg['content']) for msg in current_messages
|
||||
)
|
||||
merged_traj.append({'role': 'user', 'content': merged_content})
|
||||
|
||||
# Now process the merged trajectory
|
||||
for i, message in enumerate(merged_traj):
|
||||
role, content = message['role'], message['content']
|
||||
content = _convert_content(content) if isinstance(content, list) else content
|
||||
turn_id = i // 2 + 1
|
||||
output += '-' * 100 + '\n'
|
||||
output += f'*** Turn {turn_id} - {role.upper() if role != "tool" else "TOOL EXECUTION RESULT"} ***\n'
|
||||
|
||||
if role == 'user':
|
||||
output += f'{content}\n'
|
||||
elif role == 'tool':
|
||||
output += f'{content}\n'
|
||||
elif role == 'assistant':
|
||||
output += f'{content}\n'
|
||||
if (
|
||||
'tool_calls' in message
|
||||
and message['tool_calls'] is not None
|
||||
and len(message['tool_calls']) > 0
|
||||
):
|
||||
for toolcall_id, tool_call in enumerate(message['tool_calls']):
|
||||
output += f'### Tool Call {toolcall_id}\n'
|
||||
output += f'{convert_tool_call_to_string(tool_call)}\n'
|
||||
else:
|
||||
raise ValueError(f'Unexpected role: {role}')
|
||||
|
||||
output += '-' * 100 + '\n'
|
||||
return output
|
||||
|
||||
|
||||
def write_row_to_md_file(row, instance_id_to_test_result):
|
||||
if 'git_patch' in row:
|
||||
model_patch = row['git_patch']
|
||||
elif 'test_result' in row and 'git_patch' in row['test_result']:
|
||||
model_patch = row['test_result']['git_patch']
|
||||
else:
|
||||
print(f'Row {row} does not have a git_patch')
|
||||
return
|
||||
raise ValueError(f'Row {row} does not have a git_patch')
|
||||
|
||||
test_output = None
|
||||
# Use result from output.jsonl FIRST if available.
|
||||
if 'report' in row and row['report'] is not None:
|
||||
if row['instance_id'] in instance_id_to_test_result:
|
||||
report = instance_id_to_test_result[row['instance_id']].get('report', {})
|
||||
resolved = report.get('resolved', False)
|
||||
test_output = instance_id_to_test_result[row['instance_id']].get(
|
||||
'test_output', None
|
||||
)
|
||||
elif 'report' in row and row['report'] is not None:
|
||||
if not isinstance(row['report'], dict):
|
||||
resolved = None
|
||||
print(
|
||||
@@ -232,12 +80,6 @@ def write_row_to_md_file(row, instance_id_to_test_result):
|
||||
)
|
||||
else:
|
||||
resolved = row['report'].get('resolved', False)
|
||||
elif row['instance_id'] in instance_id_to_test_result:
|
||||
report = instance_id_to_test_result[row['instance_id']].get('report', {})
|
||||
resolved = report.get('resolved', False)
|
||||
test_output = instance_id_to_test_result[row['instance_id']].get(
|
||||
'test_output', None
|
||||
)
|
||||
else:
|
||||
resolved = None
|
||||
|
||||
@@ -246,8 +88,6 @@ def write_row_to_md_file(row, instance_id_to_test_result):
|
||||
os.makedirs(output_md_folder, exist_ok=True)
|
||||
filepath = os.path.join(output_md_folder, filename)
|
||||
|
||||
completions = load_completions(instance_id)
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(f'# {instance_id} (resolved: {resolved})\n')
|
||||
|
||||
@@ -257,12 +97,7 @@ def write_row_to_md_file(row, instance_id_to_test_result):
|
||||
f.write(json.dumps(row['metadata'], indent=2))
|
||||
f.write('\n```\n')
|
||||
|
||||
# Completion
|
||||
if completions is not None:
|
||||
f.write('## Completion\n')
|
||||
traj = completions['messages']
|
||||
f.write(format_traj(traj))
|
||||
|
||||
# Trajectory
|
||||
f.write('## History\n')
|
||||
f.write(convert_history_to_str(row['history']))
|
||||
|
||||
|
||||
@@ -207,13 +207,12 @@ with open(args.input_file, 'r') as infile:
|
||||
for line in tqdm(infile, desc='Checking for changes'):
|
||||
data = json.loads(line)
|
||||
instance_id = data['instance_id']
|
||||
current_report = data.get('report', {})
|
||||
new_report = instance_id_to_status[
|
||||
instance_id
|
||||
] # if no report, it's not resolved
|
||||
if current_report != new_report:
|
||||
needs_update = True
|
||||
break
|
||||
if instance_id in instance_id_to_status:
|
||||
current_report = data.get('report', {})
|
||||
new_report = instance_id_to_status[instance_id]
|
||||
if current_report != new_report:
|
||||
needs_update = True
|
||||
break
|
||||
|
||||
if not needs_update:
|
||||
print('No updates detected. Skipping file update.')
|
||||
@@ -235,5 +234,6 @@ with open(args.input_file + '.bak', 'r') as infile, open(
|
||||
for line in tqdm(infile, desc='Updating output file'):
|
||||
data = json.loads(line)
|
||||
instance_id = data['instance_id']
|
||||
data['report'] = instance_id_to_status[instance_id]
|
||||
if instance_id in instance_id_to_status:
|
||||
data['report'] = instance_id_to_status[instance_id]
|
||||
outfile.write(json.dumps(data) + '\n')
|
||||
|
||||
@@ -76,7 +76,7 @@ echo "Running SWE-bench evaluation"
|
||||
echo "=============================================================="
|
||||
|
||||
RUN_ID=$(date +"%Y%m%d_%H%M%S")
|
||||
N_PROCESS=4
|
||||
N_PROCESS=16
|
||||
|
||||
if [ -z "$INSTANCE_ID" ]; then
|
||||
echo "Running SWE-bench evaluation on the whole input file..."
|
||||
@@ -87,7 +87,7 @@ if [ -z "$INSTANCE_ID" ]; then
|
||||
--dataset_name "$DATASET_NAME" \
|
||||
--split "$SPLIT" \
|
||||
--predictions_path $SWEBENCH_FORMAT_JSONL \
|
||||
--timeout 3600 \
|
||||
--timeout 1800 \
|
||||
--cache_level instance \
|
||||
--max_workers $N_PROCESS \
|
||||
--run_id $RUN_ID
|
||||
@@ -133,7 +133,7 @@ else
|
||||
--dataset_name "$DATASET_NAME" \
|
||||
--split "$SPLIT" \
|
||||
--predictions_path $SWEBENCH_FORMAT_JSONL \
|
||||
--timeout 3600 \
|
||||
--timeout 1800 \
|
||||
--instance_ids $INSTANCE_ID \
|
||||
--cache_level instance \
|
||||
--max_workers $N_PROCESS \
|
||||
|
||||
674
evaluation/benchmarks/visualcodebench/eval.py
Normal file
674
evaluation/benchmarks/visualcodebench/eval.py
Normal file
@@ -0,0 +1,674 @@
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
from difflib import SequenceMatcher
|
||||
from io import BytesIO
|
||||
|
||||
from bs4 import BeautifulSoup, Comment, NavigableString, Tag
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
from colormath.color_conversions import convert_color
|
||||
from colormath.color_diff import delta_e_cie2000
|
||||
from colormath.color_objects import LabColor, sRGBColor
|
||||
from PIL import Image, ImageChops, ImageColor
|
||||
from scipy.optimize import linear_sum_assignment
|
||||
from transformers import CLIPModel, CLIPProcessor
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def calculate_similarity(block1, block2):
|
||||
"""Calculate text similarity between two blocks using SequenceMatcher."""
|
||||
text_similarity = SequenceMatcher(None, block1['text'], block2['text']).ratio()
|
||||
return text_similarity
|
||||
|
||||
|
||||
def adjust_cost_for_context(cost_matrix, consecutive_bonus=1.0, window_size=20):
|
||||
"""Adjust cost matrix by considering context similarity."""
|
||||
if window_size <= 0:
|
||||
return cost_matrix
|
||||
|
||||
n, m = cost_matrix.shape
|
||||
adjusted_cost_matrix = np.copy(cost_matrix)
|
||||
|
||||
for i in range(n):
|
||||
for j in range(m):
|
||||
if adjusted_cost_matrix[i][j] >= -0.5:
|
||||
continue
|
||||
nearby_matrix = cost_matrix[
|
||||
max(0, i - window_size) : min(n, i + window_size + 1),
|
||||
max(0, j - window_size) : min(m, j + window_size + 1),
|
||||
]
|
||||
flattened_array = nearby_matrix.flatten()
|
||||
sorted_array = np.sort(flattened_array)[::-1]
|
||||
sorted_array = np.delete(
|
||||
sorted_array, np.where(sorted_array == cost_matrix[i, j])[0][0]
|
||||
)
|
||||
top_k_elements = sorted_array[-window_size * 2 :]
|
||||
bonus = consecutive_bonus * np.sum(top_k_elements)
|
||||
adjusted_cost_matrix[i][j] += bonus
|
||||
return adjusted_cost_matrix
|
||||
|
||||
|
||||
def create_cost_matrix(A, B):
|
||||
"""Create cost matrix for block matching."""
|
||||
n = len(A)
|
||||
m = len(B)
|
||||
cost_matrix = np.zeros((n, m))
|
||||
for i in range(n):
|
||||
for j in range(m):
|
||||
cost_matrix[i, j] = -calculate_similarity(A[i], B[j])
|
||||
return cost_matrix
|
||||
|
||||
|
||||
def calculate_distance_max_1d(x1, y1, x2, y2):
|
||||
"""Calculate maximum 1D distance between points."""
|
||||
return max(abs(x2 - x1), abs(y2 - y1))
|
||||
|
||||
|
||||
def calculate_ratio(h1, h2):
|
||||
"""Calculate ratio between two heights."""
|
||||
return max(h1, h2) / min(h1, h2)
|
||||
|
||||
|
||||
def rgb_to_lab(rgb):
|
||||
"""Convert RGB color to Lab color space."""
|
||||
rgb_color = sRGBColor(rgb[0], rgb[1], rgb[2], is_upscaled=True)
|
||||
lab_color = convert_color(rgb_color, LabColor)
|
||||
return lab_color
|
||||
|
||||
|
||||
def color_similarity_ciede2000(rgb1, rgb2):
|
||||
"""Calculate color similarity using CIEDE2000 formula."""
|
||||
lab1 = rgb_to_lab(rgb1)
|
||||
lab2 = rgb_to_lab(rgb2)
|
||||
delta_e = delta_e_cie2000(lab1, lab2)
|
||||
similarity = max(0, 1 - (delta_e / 100))
|
||||
return similarity
|
||||
|
||||
|
||||
def merge_blocks_wo_check(block1, block2):
|
||||
"""Merge two blocks without additional checks."""
|
||||
merged_text = block1['text'] + ' ' + block2['text']
|
||||
x_min = min(block1['bbox'][0], block2['bbox'][0])
|
||||
y_min = min(block1['bbox'][1], block2['bbox'][1])
|
||||
x_max = max(
|
||||
block1['bbox'][0] + block1['bbox'][2], block2['bbox'][0] + block2['bbox'][2]
|
||||
)
|
||||
y_max = max(
|
||||
block1['bbox'][1] + block1['bbox'][3], block2['bbox'][1] + block2['bbox'][3]
|
||||
)
|
||||
merged_bbox = (x_min, y_min, x_max - x_min, y_max - y_min)
|
||||
merged_color = tuple(
|
||||
(color1 + color2) // 2
|
||||
for color1, color2 in zip(block1['color'], block2['color'])
|
||||
)
|
||||
return {'text': merged_text, 'bbox': merged_bbox, 'color': merged_color}
|
||||
|
||||
|
||||
def find_maximum_matching(A, B, consecutive_bonus, window_size):
|
||||
"""Find maximum matching between two sets of blocks."""
|
||||
cost_matrix = create_cost_matrix(A, B)
|
||||
cost_matrix = adjust_cost_for_context(cost_matrix, consecutive_bonus, window_size)
|
||||
row_ind, col_ind = linear_sum_assignment(cost_matrix)
|
||||
current_cost = cost_matrix[row_ind, col_ind].tolist()
|
||||
return list(zip(row_ind, col_ind)), current_cost, cost_matrix
|
||||
|
||||
|
||||
def remove_indices(lst, indices):
|
||||
"""Remove indices from list in reverse order."""
|
||||
for index in sorted(indices, reverse=True):
|
||||
if index < len(lst):
|
||||
lst.pop(index)
|
||||
return lst
|
||||
|
||||
|
||||
def merge_blocks_by_list(blocks, merge_list):
|
||||
"""Merge blocks according to merge list."""
|
||||
pop_list = []
|
||||
while merge_list:
|
||||
i = merge_list[0][0]
|
||||
j = merge_list[0][1]
|
||||
blocks[i] = merge_blocks_wo_check(blocks[i], blocks[j])
|
||||
pop_list.append(j)
|
||||
merge_list.pop(0)
|
||||
if merge_list:
|
||||
new_merge_list = []
|
||||
for k in range(len(merge_list)):
|
||||
if (
|
||||
merge_list[k][0] != i
|
||||
and merge_list[k][1] != i
|
||||
and merge_list[k][0] != j
|
||||
and merge_list[k][1] != j
|
||||
):
|
||||
new_merge_list.append(merge_list[k])
|
||||
merge_list = new_merge_list
|
||||
remove_indices(blocks, pop_list)
|
||||
return blocks
|
||||
|
||||
|
||||
def difference_of_means(list1, list2):
|
||||
"""Calculate difference of means between two lists."""
|
||||
counter1 = Counter(list1)
|
||||
counter2 = Counter(list2)
|
||||
|
||||
for element in set(list1) & set(list2):
|
||||
common_count = min(counter1[element], counter2[element])
|
||||
counter1[element] -= common_count
|
||||
counter2[element] -= common_count
|
||||
|
||||
unique_list1 = [item for item in counter1.elements()]
|
||||
unique_list2 = [item for item in counter2.elements()]
|
||||
|
||||
mean_list1 = sum(unique_list1) / len(unique_list1) if unique_list1 else 0
|
||||
mean_list2 = sum(unique_list2) / len(unique_list2) if unique_list2 else 0
|
||||
|
||||
if mean_list1 - mean_list2 > 0:
|
||||
if min(unique_list1) > min(unique_list2):
|
||||
return mean_list1 - mean_list2
|
||||
return 0.0
|
||||
return mean_list1 - mean_list2
|
||||
|
||||
|
||||
def find_possible_merge(A, B, consecutive_bonus, window_size, debug=False):
|
||||
"""Find possible merges between blocks."""
|
||||
merge_bonus = 0.0
|
||||
merge_windows = 1
|
||||
|
||||
def sortFn(value):
|
||||
return value[2]
|
||||
|
||||
while True:
|
||||
A_changed = False
|
||||
B_changed = False
|
||||
|
||||
matching, current_cost, cost_matrix = find_maximum_matching(
|
||||
A, B, merge_bonus, merge_windows
|
||||
)
|
||||
|
||||
if len(A) >= 2:
|
||||
merge_list = []
|
||||
for i in range(len(A) - 1):
|
||||
new_A = deepcopy(A)
|
||||
new_A[i] = merge_blocks_wo_check(new_A[i], new_A[i + 1])
|
||||
new_A.pop(i + 1)
|
||||
updated_matching, updated_cost, _ = find_maximum_matching(
|
||||
new_A, B, merge_bonus, merge_windows
|
||||
)
|
||||
diff = difference_of_means(current_cost, updated_cost)
|
||||
if diff > 0.05:
|
||||
merge_list.append([i, i + 1, diff])
|
||||
|
||||
merge_list.sort(key=sortFn, reverse=True)
|
||||
if merge_list:
|
||||
A_changed = True
|
||||
A = merge_blocks_by_list(A, merge_list)
|
||||
matching, current_cost, cost_matrix = find_maximum_matching(
|
||||
A, B, merge_bonus, merge_windows
|
||||
)
|
||||
|
||||
if len(B) >= 2:
|
||||
merge_list = []
|
||||
for i in range(len(B) - 1):
|
||||
new_B = deepcopy(B)
|
||||
new_B[i] = merge_blocks_wo_check(new_B[i], new_B[i + 1])
|
||||
new_B.pop(i + 1)
|
||||
updated_matching, updated_cost, _ = find_maximum_matching(
|
||||
A, new_B, merge_bonus, merge_windows
|
||||
)
|
||||
diff = difference_of_means(current_cost, updated_cost)
|
||||
if diff > 0.05:
|
||||
merge_list.append([i, i + 1, diff])
|
||||
|
||||
merge_list.sort(key=sortFn, reverse=True)
|
||||
if merge_list:
|
||||
B_changed = True
|
||||
B = merge_blocks_by_list(B, merge_list)
|
||||
matching, current_cost, cost_matrix = find_maximum_matching(
|
||||
A, B, merge_bonus, merge_windows
|
||||
)
|
||||
|
||||
if not A_changed and not B_changed:
|
||||
break
|
||||
|
||||
matching, _, _ = find_maximum_matching(A, B, consecutive_bonus, window_size)
|
||||
return A, B, matching
|
||||
|
||||
|
||||
def merge_blocks_by_bbox(blocks):
|
||||
"""Merge blocks with same bounding box."""
|
||||
merged_blocks = {}
|
||||
for block in blocks:
|
||||
bbox = tuple(block['bbox'])
|
||||
if bbox in merged_blocks:
|
||||
existing_block = merged_blocks[bbox]
|
||||
existing_block['text'] += ' ' + block['text']
|
||||
existing_block['color'] = [
|
||||
(ec + c) / 2 for ec, c in zip(existing_block['color'], block['color'])
|
||||
]
|
||||
else:
|
||||
merged_blocks[bbox] = block
|
||||
return list(merged_blocks.values())
|
||||
|
||||
|
||||
def mask_bounding_boxes_with_inpainting(image, bounding_boxes):
|
||||
"""Mask bounding boxes in image using inpainting."""
|
||||
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
|
||||
mask = np.zeros(image_cv.shape[:2], dtype=np.uint8)
|
||||
height, width = image_cv.shape[:2]
|
||||
|
||||
for bbox in bounding_boxes:
|
||||
x_ratio, y_ratio, w_ratio, h_ratio = bbox
|
||||
x = int(x_ratio * width)
|
||||
y = int(y_ratio * height)
|
||||
w = int(w_ratio * width)
|
||||
h = int(h_ratio * height)
|
||||
mask[y : y + h, x : x + w] = 255
|
||||
|
||||
inpainted_image = cv2.inpaint(image_cv, mask, 3, cv2.INPAINT_TELEA)
|
||||
return Image.fromarray(cv2.cvtColor(inpainted_image, cv2.COLOR_BGR2RGB))
|
||||
|
||||
|
||||
def rescale_and_mask(image, blocks):
|
||||
"""Rescale image and mask blocks."""
|
||||
if blocks:
|
||||
image = mask_bounding_boxes_with_inpainting(image, blocks)
|
||||
|
||||
width, height = image.size
|
||||
if width < height:
|
||||
new_size = (width, width)
|
||||
else:
|
||||
new_size = (height, height)
|
||||
|
||||
return image.resize(new_size, Image.LANCZOS)
|
||||
|
||||
|
||||
def calculate_clip_similarity(image1, image2, blocks1, blocks2):
|
||||
"""Calculate CLIP similarity between two images."""
|
||||
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
|
||||
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
|
||||
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
||||
model = model.to(device)
|
||||
|
||||
# Mask and preprocess images
|
||||
image1_masked = rescale_and_mask(image1, [block['bbox'] for block in blocks1])
|
||||
image2_masked = rescale_and_mask(image2, [block['bbox'] for block in blocks2])
|
||||
inputs = processor(
|
||||
images=[image1_masked, image2_masked], return_tensors='pt', padding=True
|
||||
)
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
# Calculate features and similarity
|
||||
with torch.no_grad():
|
||||
image_features = model.get_image_features(**inputs)
|
||||
image_features1 = image_features[0].unsqueeze(0)
|
||||
image_features2 = image_features[1].unsqueeze(0)
|
||||
image_features1 /= image_features1.norm(dim=-1, keepdim=True)
|
||||
image_features2 /= image_features2.norm(dim=-1, keepdim=True)
|
||||
similarity = (image_features1 @ image_features2.T).item()
|
||||
|
||||
return similarity
|
||||
|
||||
|
||||
def rgb_to_hex(rgb):
|
||||
"""Convert an RGB tuple to hexadecimal format."""
|
||||
return '{:02X}{:02X}{:02X}'.format(*rgb)
|
||||
|
||||
|
||||
class ColorPool:
|
||||
def __init__(self, offset=0):
|
||||
color_values = list(range(10, 251, 16))
|
||||
color_list = [((r + offset) % 256, (g + offset) % 256, (b + offset) % 256)
|
||||
for r in color_values for g in color_values for b in color_values]
|
||||
self.color_pool = [rgb_to_hex(color) for color in color_list]
|
||||
|
||||
def pop_color(self):
|
||||
if self.color_pool:
|
||||
return self.color_pool.pop()
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def process_html_str(html_str, offset=0):
|
||||
"""Process HTML string to assign unique colors to text elements."""
|
||||
soup = BeautifulSoup(html_str, 'html.parser')
|
||||
|
||||
def update_style(element, property_name, value):
|
||||
important_value = f"{value} !important"
|
||||
styles = element.attrs.get('style', '').split(';')
|
||||
updated_styles = [s for s in styles if not s.strip().startswith(property_name) and len(s.strip()) > 0]
|
||||
updated_styles.append(f"{property_name}: {important_value}")
|
||||
element['style'] = '; '.join(updated_styles).strip()
|
||||
|
||||
# Set background color of all elements to transparent white
|
||||
for element in soup.find_all(True):
|
||||
update_style(element, 'background-color', 'rgba(255, 255, 255, 0.0)')
|
||||
|
||||
color_pool = ColorPool(offset)
|
||||
text_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'span', 'a', 'b', 'li',
|
||||
'table', 'td', 'th', 'button', 'footer', 'header', 'figcaption']
|
||||
|
||||
for tag in soup.find_all(text_tags):
|
||||
color = f"#{color_pool.pop_color()}"
|
||||
update_style(tag, 'color', color)
|
||||
update_style(tag, 'opacity', '1.0')
|
||||
|
||||
return str(soup)
|
||||
|
||||
|
||||
def similar(n1, n2):
|
||||
"""Check if two numbers are similar within a threshold."""
|
||||
return abs(n1 - n2) <= 8
|
||||
|
||||
|
||||
def find_different_pixels(image1, image2):
|
||||
"""Find pixels that differ between two images."""
|
||||
if image1.size != image2.size:
|
||||
logger.warning("Images are not the same size")
|
||||
return None
|
||||
|
||||
image1 = image1.convert('RGB')
|
||||
image2 = image2.convert('RGB')
|
||||
pixels1 = image1.load()
|
||||
pixels2 = image2.load()
|
||||
different_pixels = []
|
||||
|
||||
for x in range(image1.size[0]):
|
||||
for y in range(image1.size[1]):
|
||||
r1, g1, b1 = pixels1[x, y]
|
||||
r2, g2, b2 = pixels2[x, y]
|
||||
if similar((r1 + 50) % 256, r2) and similar((g1 + 50) % 256, g2) and similar((b1 + 50) % 256, b2):
|
||||
different_pixels.append((y, x))
|
||||
|
||||
return np.stack(different_pixels) if different_pixels else None
|
||||
|
||||
|
||||
def extract_text_with_color(html_str):
|
||||
"""Extract text and color information from HTML string."""
|
||||
def get_color(tag):
|
||||
if 'style' in tag.attrs:
|
||||
styles = tag['style'].split(';')
|
||||
color_style = [s for s in styles if 'color' in s and 'background-color' not in s]
|
||||
if color_style:
|
||||
color = color_style[-1].split(':')[1].strip().replace(" !important", "")
|
||||
if color[0] == "#":
|
||||
return color
|
||||
else:
|
||||
try:
|
||||
if color.startswith('rgb'):
|
||||
color = tuple(map(int, color[4:-1].split(',')))
|
||||
else:
|
||||
color = ImageColor.getrgb(color)
|
||||
return '#{:02x}{:02x}{:02x}'.format(*color)
|
||||
except ValueError:
|
||||
logger.warning(f"Unable to identify or convert color: {color}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def extract_text_recursive(element, parent_color='#000000'):
|
||||
if isinstance(element, Comment):
|
||||
return None
|
||||
elif isinstance(element, NavigableString):
|
||||
text = element.strip()
|
||||
return (text, parent_color) if text else None
|
||||
elif isinstance(element, Tag):
|
||||
current_color = get_color(element) or parent_color
|
||||
children_texts = filter(None, [extract_text_recursive(child, current_color)
|
||||
for child in element.children])
|
||||
return list(children_texts)
|
||||
|
||||
soup = BeautifulSoup(html_str, 'html.parser')
|
||||
body = soup.body
|
||||
return extract_text_recursive(body) if body else []
|
||||
|
||||
|
||||
def flatten_tree(tree):
|
||||
"""Flatten a nested tree structure into a list."""
|
||||
flat_list = []
|
||||
def flatten(node):
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
flatten(item)
|
||||
else:
|
||||
flat_list.append(node)
|
||||
flatten(tree)
|
||||
return flat_list
|
||||
|
||||
|
||||
def get_blocks_from_image_diff_pixels(image, html_text_color_tree, different_pixels):
|
||||
"""Extract text blocks from image using color differences."""
|
||||
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
|
||||
x_w = image_cv.shape[0]
|
||||
y_w = image_cv.shape[1]
|
||||
|
||||
def hex_to_bgr(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
return rgb[::-1]
|
||||
|
||||
def get_intersect(arr1, arr2):
|
||||
arr1_reshaped = arr1.view([('', arr1.dtype)] * arr1.shape[1])
|
||||
arr2_reshaped = arr2.view([('', arr2.dtype)] * arr2.shape[1])
|
||||
common_rows = np.intersect1d(arr1_reshaped, arr2_reshaped)
|
||||
return common_rows.view(arr1.dtype).reshape(-1, arr1.shape[1])
|
||||
|
||||
blocks = []
|
||||
for item in html_text_color_tree:
|
||||
try:
|
||||
color = np.array(hex_to_bgr(item[1]), dtype="uint8")
|
||||
except:
|
||||
continue
|
||||
|
||||
lower = color - 4
|
||||
upper = color + 4
|
||||
mask = cv2.inRange(image_cv, lower, upper)
|
||||
coords = np.column_stack(np.where(mask > 0))
|
||||
coords = get_intersect(coords, different_pixels)
|
||||
|
||||
if coords.size == 0:
|
||||
continue
|
||||
|
||||
x_min, y_min = np.min(coords, axis=0)
|
||||
x_max, y_max = np.max(coords, axis=0)
|
||||
|
||||
# Get average color from original image
|
||||
color_coords = coords.copy()
|
||||
color_coords = color_coords[color_coords[:, 0] <= x_max]
|
||||
color_coords = color_coords[color_coords[:, 1] <= y_max]
|
||||
colors = [image_cv[x, y] for x, y in color_coords]
|
||||
avg_color = tuple(map(int, np.mean(colors, axis=0)))[::-1] # Convert BGR to RGB
|
||||
|
||||
blocks.append({
|
||||
'text': item[0].lower(),
|
||||
'bbox': (y_min / y_w, x_min / x_w, (y_max - y_min + 1) / y_w, (x_max - x_min + 1) / x_w),
|
||||
'color': avg_color
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def get_blocks_from_html(html_str, image1):
|
||||
"""Extract text blocks from HTML and image."""
|
||||
# Process HTML with two different color offsets
|
||||
html_str_1 = process_html_str(html_str, offset=0)
|
||||
html_str_2 = process_html_str(html_str, offset=50)
|
||||
|
||||
# Render both HTML versions to images
|
||||
# TODO: Screenshot html_str_2
|
||||
filter_color = (255, 0, 0)
|
||||
image2 = Image.new("RGB", image1.size, filter_color)
|
||||
|
||||
|
||||
# Find pixels that differ between the two rendered images
|
||||
different_pixels = find_different_pixels(image1, image2)
|
||||
if different_pixels is None:
|
||||
logger.warning("Unable to get pixels with different colors")
|
||||
return []
|
||||
|
||||
# Extract text and color information from HTML
|
||||
html_text_color_tree = flatten_tree(extract_text_with_color(html_str_1))
|
||||
try:
|
||||
blocks = get_blocks_from_image_diff_pixels(image1, html_text_color_tree, different_pixels)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get blocks: {e}")
|
||||
return []
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def evaluate(task, generated_img):
|
||||
"""Evaluate generated image against reference image using multiple metrics."""
|
||||
# Load reference image
|
||||
post_image = task['post_image']
|
||||
|
||||
# Extract blocks from HTML and images
|
||||
post_blocks = get_blocks_from_html(task['post_html'], post_image)
|
||||
gen_blocks = get_blocks_from_html(task['gen_html'], generated_img)
|
||||
|
||||
print("block details", post_blocks, gen_blocks)
|
||||
if not post_blocks or not gen_blocks:
|
||||
# Fallback to basic CLIP and pixel comparison if no blocks available
|
||||
clip_score = calculate_clip_similarity(post_image, generated_img, [], [])
|
||||
logger.info(f'CLIP similarity score: {clip_score}')
|
||||
|
||||
# Pixel comparison
|
||||
diff = ImageChops.difference(generated_img, post_image)
|
||||
pixel_match = not diff.getbbox()
|
||||
logger.info(
|
||||
f"Pixel difference analysis: {'No difference' if pixel_match else 'Differences found'}"
|
||||
)
|
||||
|
||||
return clip_score > 0.95 or pixel_match
|
||||
|
||||
# Merge blocks with same bounding boxes
|
||||
post_blocks = merge_blocks_by_bbox(post_blocks)
|
||||
gen_blocks = merge_blocks_by_bbox(gen_blocks)
|
||||
|
||||
# Find optimal block matching
|
||||
consecutive_bonus, window_size = 0.1, 1
|
||||
gen_blocks_m, post_blocks_m, matching = find_possible_merge(
|
||||
gen_blocks, deepcopy(post_blocks), consecutive_bonus, window_size
|
||||
)
|
||||
|
||||
# Filter matches with low similarity
|
||||
filtered_matching = []
|
||||
for i, j in matching:
|
||||
text_similarity = calculate_similarity(gen_blocks_m[i], post_blocks_m[j])
|
||||
if text_similarity >= 0.5:
|
||||
filtered_matching.append([i, j, text_similarity])
|
||||
matching = filtered_matching
|
||||
|
||||
if not matching:
|
||||
logger.warning('No matching blocks found')
|
||||
clip_score = calculate_clip_similarity(
|
||||
post_image, generated_img, gen_blocks, post_blocks
|
||||
)
|
||||
return clip_score > 0.95
|
||||
|
||||
# Calculate metrics for matched blocks
|
||||
indices1 = [item[0] for item in matching]
|
||||
indices2 = [item[1] for item in matching]
|
||||
|
||||
# Calculate unmatched areas
|
||||
unmatched_area_1 = sum(
|
||||
block['bbox'][2] * block['bbox'][3]
|
||||
for i, block in enumerate(gen_blocks_m)
|
||||
if i not in indices1
|
||||
)
|
||||
unmatched_area_2 = sum(
|
||||
block['bbox'][2] * block['bbox'][3]
|
||||
for j, block in enumerate(post_blocks_m)
|
||||
if j not in indices2
|
||||
)
|
||||
total_unmatched_area = unmatched_area_1 + unmatched_area_2
|
||||
|
||||
# Calculate metrics for matched blocks
|
||||
matched_areas = []
|
||||
text_scores = []
|
||||
position_scores = []
|
||||
color_scores = []
|
||||
|
||||
for i, j, text_similarity in matching:
|
||||
# Area
|
||||
block_area = (
|
||||
gen_blocks_m[i]['bbox'][2] * gen_blocks_m[i]['bbox'][3]
|
||||
+ post_blocks_m[j]['bbox'][2] * post_blocks_m[j]['bbox'][3]
|
||||
)
|
||||
matched_areas.append(block_area)
|
||||
|
||||
# Position similarity
|
||||
position_similarity = 1 - calculate_distance_max_1d(
|
||||
gen_blocks_m[i]['bbox'][0] + gen_blocks_m[i]['bbox'][2] / 2,
|
||||
gen_blocks_m[i]['bbox'][1] + gen_blocks_m[i]['bbox'][3] / 2,
|
||||
post_blocks_m[j]['bbox'][0] + post_blocks_m[j]['bbox'][2] / 2,
|
||||
post_blocks_m[j]['bbox'][1] + post_blocks_m[j]['bbox'][3] / 2,
|
||||
)
|
||||
|
||||
# Color similarity
|
||||
color_similarity = color_similarity_ciede2000(
|
||||
gen_blocks_m[i]['color'], post_blocks_m[j]['color']
|
||||
)
|
||||
|
||||
text_scores.append(text_similarity)
|
||||
position_scores.append(position_similarity)
|
||||
color_scores.append(color_similarity)
|
||||
|
||||
# Calculate final scores
|
||||
total_area = sum(matched_areas) + total_unmatched_area
|
||||
size_score = sum(matched_areas) / total_area if total_area > 0 else 0
|
||||
text_score = np.mean(text_scores) if text_scores else 0
|
||||
position_score = np.mean(position_scores) if position_scores else 0
|
||||
color_score = np.mean(color_scores) if color_scores else 0
|
||||
clip_score = calculate_clip_similarity(
|
||||
post_image, generated_img, gen_blocks, post_blocks
|
||||
)
|
||||
|
||||
# Combine scores with equal weights
|
||||
final_score = 0.2 * (
|
||||
size_score + text_score + position_score + color_score + clip_score
|
||||
)
|
||||
|
||||
logger.info('Evaluation scores:')
|
||||
logger.info(f'- Size score: {size_score:.3f}')
|
||||
logger.info(f'- Text score: {text_score:.3f}')
|
||||
logger.info(f'- Position score: {position_score:.3f}')
|
||||
logger.info(f'- Color score: {color_score:.3f}')
|
||||
logger.info(f'- CLIP score: {clip_score:.3f}')
|
||||
logger.info(f'- Final score: {final_score:.3f}')
|
||||
|
||||
return final_score > 0.8 # Consider it a match if final score > 80%
|
||||
|
||||
|
||||
def png_to_bytes(png):
|
||||
buffer = BytesIO()
|
||||
png.save(buffer, format='PNG')
|
||||
image_bytes = buffer.getvalue()
|
||||
return image_bytes
|
||||
|
||||
|
||||
def bytes_to_image(image_bytes):
|
||||
"""Convert bytes to a Pillow Image object."""
|
||||
return Image.open(BytesIO(image_bytes))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
first_image = Image.open('./evaluation/visualcodebench/data/1/post.png')
|
||||
image = Image.open('./evaluation/visualcodebench/data/1/prev.png')
|
||||
|
||||
|
||||
html_file = open('./evaluation/visualcodebench/data/1/post/index.html', 'r')
|
||||
first_html = html_file.read()
|
||||
html_file.close()
|
||||
|
||||
html_file = open('./evaluation/visualcodebench/data/1/prev/index.html', 'r')
|
||||
gen_html = html_file.read()
|
||||
html_file.close()
|
||||
|
||||
|
||||
|
||||
sample = {'post_image': first_image, "post_html": first_html, "gen_html": gen_html}
|
||||
|
||||
|
||||
|
||||
evaluate(sample, image)
|
||||
|
||||
97
evaluation/benchmarks/visualcodebench/prepare.py
Normal file
97
evaluation/benchmarks/visualcodebench/prepare.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
import pandas as pd
|
||||
from huggingface_hub import snapshot_download
|
||||
from PIL import PngImagePlugin
|
||||
from tqdm import tqdm
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
REPO_DOWNLOAD_DIR = (
|
||||
'./evaluation/visualcodebench/' # Directory to store the downloaded repository
|
||||
)
|
||||
|
||||
|
||||
def download_repository():
|
||||
"""
|
||||
Download the entire repository from Hugging Face Hub.
|
||||
This function clones the repository into REPO_DOWNLOAD_DIR.
|
||||
"""
|
||||
repo_id = 'rvmalhot/VisualCodeBench'
|
||||
try:
|
||||
logger.info(f"Downloading repository '{repo_id}'...")
|
||||
snapshot_download(
|
||||
repo_id=repo_id,
|
||||
local_dir=REPO_DOWNLOAD_DIR,
|
||||
repo_type='dataset',
|
||||
ignore_patterns=None, # Download all files
|
||||
)
|
||||
logger.info(f"Repository downloaded to '{REPO_DOWNLOAD_DIR}'.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading repository '{repo_id}': {e}")
|
||||
raise e
|
||||
|
||||
|
||||
def format_task_dict(example):
|
||||
instance_id = example['id']
|
||||
prev_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/prev')
|
||||
post_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/post')
|
||||
|
||||
# Check if 'prev' and 'post' directories exist
|
||||
prev_exists = os.path.exists(prev_remote_path)
|
||||
post_exists = os.path.exists(post_remote_path)
|
||||
|
||||
if prev_exists and post_exists:
|
||||
skip = False
|
||||
else:
|
||||
skip = True
|
||||
|
||||
task = {
|
||||
'instance_id': instance_id,
|
||||
'prev_image': example['prev_image'],
|
||||
'post_image': example['post_image'],
|
||||
'changes': example['changes'],
|
||||
'prev_code_files': example['prev_code_files'],
|
||||
'post_code_files': example['post_code_files'],
|
||||
'skip': skip,
|
||||
}
|
||||
|
||||
return task
|
||||
|
||||
|
||||
def prepare_visualcodebench(dataset):
|
||||
logger.info('Processing dataset')
|
||||
dataset_processed = []
|
||||
for example in tqdm(dataset['train']):
|
||||
formatted_example = format_task_dict(example)
|
||||
if formatted_example['skip']:
|
||||
continue
|
||||
del formatted_example['skip']
|
||||
dataset_processed.append(formatted_example)
|
||||
|
||||
return pd.DataFrame(dataset_processed)
|
||||
|
||||
|
||||
def pil_image_to_base64(image: PngImagePlugin.PngImageFile) -> str:
|
||||
"""
|
||||
Converts a PIL image to a Base64-encoded string.
|
||||
|
||||
Parameters:
|
||||
- image (PngImagePlugin.PngImageFile): The PIL image to convert.
|
||||
|
||||
Returns:
|
||||
- str: The Base64-encoded string of the image.
|
||||
"""
|
||||
if not isinstance(image, PngImagePlugin.PngImageFile):
|
||||
raise ValueError(
|
||||
'The provided image is not a PIL.PngImagePlugin.PngImageFile instance.'
|
||||
)
|
||||
|
||||
buffered = BytesIO()
|
||||
image.save(buffered, format='PNG')
|
||||
img_bytes = buffered.getvalue()
|
||||
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
|
||||
base64_with_prefix = f'data:image/png;base64,{img_base64}'
|
||||
return [base64_with_prefix]
|
||||
247
evaluation/benchmarks/visualcodebench/run_infer.py
Normal file
247
evaluation/benchmarks/visualcodebench/run_infer.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# FILE: run_infer.py
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from functools import partial
|
||||
|
||||
import pandas as pd
|
||||
from datasets import load_dataset
|
||||
|
||||
# from evaluation.benchmarks.visualcodebench.eval import capture_screenshot
|
||||
from evaluation.benchmarks.visualcodebench.prepare import (
|
||||
REPO_DOWNLOAD_DIR,
|
||||
download_repository,
|
||||
pil_image_to_base64,
|
||||
prepare_visualcodebench,
|
||||
)
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
SandboxConfig,
|
||||
get_llm_config_arg,
|
||||
)
|
||||
from openhands.core.config.utils import parse_arguments
|
||||
from openhands.core.logger import openhands_logger as logger # Import OpenHands logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action.commands import CmdRunAction
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.observation.commands import CmdOutputObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
|
||||
# Define workspace and output directories
|
||||
WORKSPACE_DIR = './workspace'
|
||||
|
||||
FAKE_RESPONSES = {
|
||||
'CodeActAgent': partial(codeact_user_response, encapsulate_solution=True),
|
||||
}
|
||||
|
||||
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> AppConfig:
|
||||
config = AppConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='eventstream',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image='python:3.12-bookworm',
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
return config
|
||||
|
||||
|
||||
def initialize_runtime(
|
||||
runtime: Runtime,
|
||||
instance: pd.Series, # this argument is not required
|
||||
):
|
||||
"""Initialize the runtime for the agent.
|
||||
|
||||
This function is called before the runtime is used to run the agent.
|
||||
"""
|
||||
logger.info('-' * 30)
|
||||
logger.info('BEGIN Runtime Initialization Fn')
|
||||
logger.info('-' * 30)
|
||||
workspace_dir_name = instance['instance_id']
|
||||
obs: CmdOutputObservation
|
||||
|
||||
action = CmdRunAction(command='mkdir -p /workspace/{workspace_dir_name}')
|
||||
action.timeout = 600
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert_and_raise(
|
||||
obs.exit_code == 0,
|
||||
f'Failed to create /workspace/{workspace_dir_name}: {str(obs)}',
|
||||
)
|
||||
|
||||
file_path = REPO_DOWNLOAD_DIR + f'data/{workspace_dir_name}/prev/index.html'
|
||||
runtime.copy_to(file_path, f'/workspace/{workspace_dir_name}')
|
||||
logger.info(f'Copied code file for instance {workspace_dir_name}')
|
||||
|
||||
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
|
||||
action.timeout = 600
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert_and_raise(
|
||||
obs.exit_code == 0,
|
||||
f'Failed to cd to /workspace/{workspace_dir_name}: {str(obs)}',
|
||||
)
|
||||
|
||||
logger.info('-' * 30)
|
||||
logger.info('END Runtime Initialization Fn')
|
||||
logger.info('-' * 30)
|
||||
|
||||
|
||||
def complete_runtime(
|
||||
runtime: Runtime,
|
||||
instance: pd.Series, # this argument is not required, but it is used to get the workspace_dir_name
|
||||
) -> str:
|
||||
# TODO: extract edited HTML file from agent workspace
|
||||
# temp_zip = runtime.copy_from(f'/workspace/{instance.instance_id}')
|
||||
# file_name = f'/workspace/{instance.instance_id}/index.html'
|
||||
# with zipfile.ZipFile(temp_zip, 'r') as zip_ref:
|
||||
# if file_name in zip_ref.namelist():
|
||||
# with zip_ref.open(file_name) as file:
|
||||
# file_content = file.read().decode('utf-8') # Decode bytes to string
|
||||
# else:
|
||||
# raise FileNotFoundError(f"'{file_name}' not found in the ZIP archive.")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_folder = REPO_DOWNLOAD_DIR + f'data/{instance.instance_id}/post/'
|
||||
shutil.copytree(src_folder, tmpdir, dirs_exist_ok=True)
|
||||
|
||||
# image = capture_screenshot(tmpdir)
|
||||
# if image is not None:
|
||||
# shutil.copy(os.path.join(tmpdir, 'final_screenshot.png'), REPO_DOWNLOAD_DIR)
|
||||
|
||||
|
||||
def process_instance(
|
||||
instance: pd.Series, metadata: EvalMetadata, reset_logger: bool = True
|
||||
):
|
||||
config = get_config(metadata)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
|
||||
reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
|
||||
else:
|
||||
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
|
||||
|
||||
# =============================================
|
||||
# build instruction
|
||||
# =============================================
|
||||
|
||||
# Prepare instruction
|
||||
instruction = (
|
||||
f"Modify the HTML/CSS according to the following instruction:\n\n"
|
||||
f"{instance['changes']}\n\n"
|
||||
)
|
||||
instruction += (
|
||||
'IMPORTANT: You should ONLY interact with the environment provided '
|
||||
'to you AND NEVER ASK FOR HUMAN HELP.\n'
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# create sandbox and run the agent
|
||||
# =============================================
|
||||
|
||||
runtime: Runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
try:
|
||||
initialize_runtime(runtime, instance=instance)
|
||||
|
||||
image_urls = pil_image_to_base64(instance['prev_image'])
|
||||
|
||||
action = MessageAction(content=instruction, image_urls=image_urls)
|
||||
state: State | None = asyncio.run(
|
||||
run_controller(
|
||||
config=config,
|
||||
initial_user_action=action,
|
||||
runtime=runtime,
|
||||
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
|
||||
)
|
||||
)
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# =============================================
|
||||
# result evaluation
|
||||
# =============================================
|
||||
|
||||
return_val = complete_runtime(runtime, instance)
|
||||
logger.info(f'Return value {return_val}')
|
||||
finally:
|
||||
runtime.close()
|
||||
|
||||
# TODO: return EVAL output
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the evaluation."""
|
||||
# args = parse_args()
|
||||
args = parse_arguments()
|
||||
|
||||
logger.info(f"\n{'='*80}\nStarting VisualCodeBench Evaluation\n{'='*80}")
|
||||
logger.info(f'Agent: {args.agent_cls}')
|
||||
logger.info(f'Model: {args.llm_config}')
|
||||
logger.info(f'Max iterations: {args.max_iterations}')
|
||||
logger.info(f'Eval limit: {args.eval_n_limit}')
|
||||
logger.info(f'Num workers: {args.eval_num_workers}\n')
|
||||
logger.info(f'Eval output: {args.eval_output_dir}\n')
|
||||
|
||||
# Step 1: Download the entire repository once
|
||||
logger.info('Downloading repository...')
|
||||
download_repository()
|
||||
|
||||
# Step 2: Load Dataset
|
||||
logger.info('Loading dataset...')
|
||||
dataset = load_dataset(REPO_DOWNLOAD_DIR)
|
||||
|
||||
# Step 3: Prepare dataset
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
if llm_config is None:
|
||||
logger.error(f'Could not find LLM config: {args.llm_config}')
|
||||
raise ValueError(f'Could not find LLM config: {args.llm_config}')
|
||||
|
||||
metadata = make_metadata(
|
||||
llm_config,
|
||||
'VisualCodeBench',
|
||||
args.agent_cls,
|
||||
args.max_iterations,
|
||||
args.eval_note,
|
||||
'evaluation/output/',
|
||||
)
|
||||
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
dataset = prepare_visualcodebench(dataset)
|
||||
instances = prepare_dataset(dataset, output_file, eval_n_limit=args.eval_n_limit)
|
||||
|
||||
# Step 4: Run eval
|
||||
run_evaluation(
|
||||
instances, metadata, output_file, args.eval_num_workers, process_instance
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
46
evaluation/benchmarks/visualcodebench/scripts/run_infer.sh
Executable file
46
evaluation/benchmarks/visualcodebench/scripts/run_infer.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
source "evaluation/utils/version_control.sh"
|
||||
|
||||
# Check if required arguments are provided
|
||||
if [ "$#" -lt 4 ]; then
|
||||
echo "Usage: $0 [model_config] [commit_hash] [agent_cls] [eval_limit] [num_workers]"
|
||||
echo "Example: $0 llm.eval_gpt_4o_mini HEAD CodeActAgent 5 1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MODEL_CONFIG=$1
|
||||
COMMIT_HASH=$2
|
||||
AGENT_CLS=$3
|
||||
EVAL_LIMIT=$4
|
||||
NUM_WORKERS=${5:-1} # Default to 1 worker if not specified
|
||||
|
||||
# Checkout the specified commit
|
||||
checkout_eval_branch
|
||||
|
||||
if [ -z "$AGENT" ]; then
|
||||
echo "Agent not specified, use default CodeActAgent"
|
||||
AGENT="CodeActAgent"
|
||||
fi
|
||||
|
||||
get_openhands_version
|
||||
|
||||
echo "AGENT: $AGENT"
|
||||
echo "OPENHANDS_VERSION: $OPENHANDS_VERSION"
|
||||
echo "MODEL_CONFIG: $MODEL_CONFIG"
|
||||
|
||||
COMMAND="export PYTHONPATH=evaluation/benchmarks/visualcodebench:\$PYTHONPATH && poetry run python evaluation/benchmarks/visualcodebench/run_infer.py \
|
||||
--agent-cls $AGENT \
|
||||
--llm-config $MODEL_CONFIG \
|
||||
--max-iterations 5 \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--eval-note $OPENHANDS_VERSION" \
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
167
evaluation/benchmarks/visualcodebench/server.py
Normal file
167
evaluation/benchmarks/visualcodebench/server.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import http
|
||||
import os
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageChops
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def get_free_port():
|
||||
"""Find a free port to run the HTTP server."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def start_http_server(tmpdir):
|
||||
port = get_free_port()
|
||||
|
||||
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def translate_path(self, path):
|
||||
# Serve files from the specified directory instead of the current working directory
|
||||
path = super().translate_path(path)
|
||||
relative_path = os.path.relpath(path, os.getcwd())
|
||||
return os.path.join(tmpdir, relative_path)
|
||||
|
||||
handler = CustomHTTPRequestHandler
|
||||
server = socketserver.TCPServer(('', port), handler)
|
||||
return server, port
|
||||
|
||||
|
||||
def capture_screenshot(tmpdir):
|
||||
server, port = start_http_server(tmpdir)
|
||||
server_thread = threading.Thread(target=server.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
time.sleep(10)
|
||||
|
||||
image = None
|
||||
try:
|
||||
server_url = f'http://localhost:{port}/'
|
||||
|
||||
if not is_server_reachable(server_url):
|
||||
raise RuntimeError(f'Server not reachable at {server_url}')
|
||||
|
||||
screenshot_path = os.path.join(tmpdir, 'final_screenshot.png')
|
||||
capture_screenshot_playwright(server_url, screenshot_path)
|
||||
image = Image.open(screenshot_path)
|
||||
image.load()
|
||||
finally:
|
||||
# Shut down the server and clean up
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def is_server_reachable(url):
|
||||
"""
|
||||
Check if the local server is reachable.
|
||||
"""
|
||||
try:
|
||||
response = requests.get(url, timeout=5) # Set a 5-second timeout
|
||||
if response.status_code == 200:
|
||||
logger.info(f'Server is reachable at {url}')
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f'Server responded with status code {response.status_code} at {url}'
|
||||
)
|
||||
return False
|
||||
except requests.ConnectionError as e:
|
||||
logger.error(f'Failed to connect to server at {url}: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def capture_screenshot_playwright(url, screenshot_path):
|
||||
"""Capture a screenshot of the given URL using Playwright."""
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
logger.info('Launching browser...')
|
||||
browser = p.chromium.launch(timeout=10000) # 10 seconds for browser launch
|
||||
|
||||
logger.info('Creating a new page...')
|
||||
page = browser.new_page()
|
||||
|
||||
logger.info(f'Navigating to URL: {url}')
|
||||
try:
|
||||
page.goto(url, timeout=60 * 1000) # Set timeout to 5 seconds
|
||||
logger.info('Page navigation completed.')
|
||||
except Exception as e:
|
||||
logger.warning(f'Page navigation timed out. {e}. Continuing...')
|
||||
|
||||
logger.info('Waiting for network to be idle...')
|
||||
try:
|
||||
page.wait_for_load_state(
|
||||
'networkidle', timeout=60 * 1000
|
||||
) # Set timeout to 5 seconds
|
||||
logger.info('Page load state reached.')
|
||||
except Exception as e:
|
||||
logger.warning(f'Page load state timed out. {e}. Continuing...')
|
||||
|
||||
logger.info('Capturing screenshot...')
|
||||
page.screenshot(
|
||||
path=screenshot_path, full_page=True
|
||||
) # Capture full page screenshot
|
||||
|
||||
logger.info(f'Screenshot saved to {screenshot_path}')
|
||||
browser.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Error capturing screenshot with Playwright: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def evaluate(task, screenshot_path):
|
||||
"""Compare generated screenshot with post_image using CLIP score."""
|
||||
try:
|
||||
import torch
|
||||
from transformers import CLIPModel, CLIPProcessor
|
||||
|
||||
# Load CLIP model and processor
|
||||
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
|
||||
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
|
||||
|
||||
# Load images
|
||||
post_image = Image.open(BytesIO(task['post_image']))
|
||||
generated_img = Image.open(screenshot_path)
|
||||
|
||||
# Process images
|
||||
inputs = processor(
|
||||
images=[post_image, generated_img], return_tensors='pt', padding=True
|
||||
)
|
||||
|
||||
# Get image features
|
||||
image_features = model.get_image_features(**inputs)
|
||||
|
||||
# Calculate cosine similarity
|
||||
similarity = torch.nn.functional.cosine_similarity(
|
||||
image_features[0].unsqueeze(0), image_features[1].unsqueeze(0)
|
||||
).item()
|
||||
|
||||
logger.info(f'CLIP similarity score: {similarity}')
|
||||
|
||||
return similarity > 0.95 # Consider it a match if similarity > 95%
|
||||
except Exception as e:
|
||||
logger.error(f'Error in CLIP evaluation: {e}')
|
||||
# Fallback to pixel comparison if CLIP fails
|
||||
try:
|
||||
post_image = Image.open(BytesIO(task['post_image']))
|
||||
generated_img = Image.open(screenshot_path)
|
||||
|
||||
# Compare images directly without converting to bytes
|
||||
diff = ImageChops.difference(generated_img, post_image)
|
||||
logger.info(
|
||||
f"Pixel difference analysis: {'No difference' if not diff.getbbox() else 'Differences found'}"
|
||||
)
|
||||
return not diff.getbbox()
|
||||
except Exception as ex:
|
||||
logger.error(f'Error in fallback evaluation: {ex}')
|
||||
return False
|
||||
@@ -4,22 +4,51 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
|
||||
|
||||
// These tests will now fail because the conversation panel is rendered through a portal
|
||||
// and technically not a child of the Sidebar component.
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
path: "/conversation/:conversationId",
|
||||
Component: () => <Sidebar />,
|
||||
},
|
||||
]);
|
||||
const renderSidebar = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
path: "/conversation/:conversationId",
|
||||
Component: Sidebar,
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSidebar = () =>
|
||||
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
};
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should have the conversation panel open by default",
|
||||
() => {
|
||||
renderSidebar();
|
||||
expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should toggle the conversation panel",
|
||||
async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const projectPanelButton = screen.getByTestId(
|
||||
"toggle-conversation-panel",
|
||||
);
|
||||
|
||||
await user.click(projectPanelButton);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-panel"),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
describe("Settings", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
@@ -47,12 +76,35 @@ describe("Sidebar", () => {
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
// the actual values are falsey (null or "") but we're checking for undefined
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: undefined,
|
||||
security_analyzer: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should send all settings data when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const menu = screen.getByTestId("account-settings-context-menu");
|
||||
const accountSettingsButton = within(menu).getByTestId(
|
||||
"account-settings-button",
|
||||
);
|
||||
await user.click(accountSettingsButton);
|
||||
|
||||
const accountSettingsModal = screen.getByTestId("account-settings-form");
|
||||
const saveButton =
|
||||
within(accountSettingsModal).getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
llm_api_key: undefined, // null or undefined
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,15 +139,9 @@ describe("Sidebar", () => {
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
github_token: "new-token",
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
language: "no",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
llm_api_key: undefined, // null or undefined
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,40 +169,11 @@ describe("Sidebar", () => {
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings Modal", () => {
|
||||
it("should open the settings modal if the user clicks the settings button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open the settings modal if GET /settings fails", async () => {
|
||||
vi.spyOn(OpenHands, "getSettings").mockRejectedValue(
|
||||
new Error("Failed to fetch settings"),
|
||||
);
|
||||
|
||||
renderSidebar();
|
||||
|
||||
const settingsModal = await screen.findByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("WaitlistModal", () => {
|
||||
});
|
||||
|
||||
it("should render a tos checkbox that is unchecked by default", () => {
|
||||
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
|
||||
render(<WaitlistModal ghToken={null} githubAuthUrl={null} />);
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
|
||||
expect(checkbox).not.toBeChecked();
|
||||
@@ -22,7 +22,7 @@ describe("WaitlistModal", () => {
|
||||
|
||||
it("should only enable the GitHub button if the tos checkbox is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
|
||||
render(<WaitlistModal ghToken={null} githubAuthUrl={null} />);
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const button = screen.getByRole("button", { name: "Connect to GitHub" });
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("WaitlistModal", () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl="mock-url" />);
|
||||
render(<WaitlistModal ghToken={null} githubAuthUrl="mock-url" />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await user.click(checkbox);
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
describe("AccountSettingsModal", () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should send all settings data when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const languageInput = screen.getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput = screen.getByTestId("github-token-input");
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "no",
|
||||
github_token: "new-token",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a checkmark and not the input if the github token is set", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const checkmark = screen.queryByTestId("github-token-set-checkmark");
|
||||
const input = screen.queryByTestId("github-token-input");
|
||||
|
||||
expect(checkmark).toBeInTheDocument();
|
||||
expect(input).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an unset github token property when pressing disconnect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const disconnectButton = await screen.findByTestId("disconnect-github");
|
||||
await user.click(disconnectButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
unset_github_token: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not unset the github token when changing the language", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const languageInput = screen.getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "no",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
28
frontend/__tests__/utils/storage.test.ts
Normal file
28
frontend/__tests__/utils/storage.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { getCachedConfig } from "../../src/utils/storage";
|
||||
|
||||
describe("getCachedConfig", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all instances and calls to constructor and all methods
|
||||
Storage.prototype.getItem = vi.fn();
|
||||
});
|
||||
|
||||
it("should return an empty object when local storage is null or undefined", () => {
|
||||
(Storage.prototype.getItem as Mock).mockReturnValue(null);
|
||||
expect(getCachedConfig()).toEqual({});
|
||||
|
||||
(Storage.prototype.getItem as Mock).mockReturnValue(undefined);
|
||||
expect(getCachedConfig()).toEqual({});
|
||||
});
|
||||
|
||||
it("should return an empty object when local storage has invalid JSON", () => {
|
||||
(Storage.prototype.getItem as Mock).mockReturnValue("invalid JSON");
|
||||
expect(getCachedConfig()).toEqual({});
|
||||
});
|
||||
|
||||
it("should return parsed object when local storage has valid JSON", () => {
|
||||
const validJSON = '{"key":"value"}';
|
||||
(Storage.prototype.getItem as Mock).mockReturnValue(validJSON);
|
||||
expect(getCachedConfig()).toEqual({ key: "value" });
|
||||
});
|
||||
});
|
||||
2379
frontend/package-lock.json
generated
2379
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,25 +9,25 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@nextui-org/react": "^2.6.11",
|
||||
"@react-router/node": "^7.1.3",
|
||||
"@react-router/serve": "^7.1.3",
|
||||
"@react-router/node": "^7.1.2",
|
||||
"@react-router/serve": "^7.1.2",
|
||||
"@react-types/shared": "^3.27.0",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@tanstack/react-query": "^5.65.1",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@tanstack/react-query": "^5.64.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.0.6",
|
||||
"i18next": "^24.2.2",
|
||||
"framer-motion": "^12.0.1",
|
||||
"i18next": "^24.2.1",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"isbot": "^5.1.21",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.211.1",
|
||||
"posthog-js": "^1.207.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -36,14 +36,14 @@
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.3",
|
||||
"react-router": "^7.1.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vite": "^6.0.11",
|
||||
"vite": "^5.4.11",
|
||||
"web-vitals": "^3.5.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
@@ -77,23 +77,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@react-router/dev": "^7.1.3",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.65.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.64.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@testing-library/user-event": "^14.6.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^3.0.4",
|
||||
"@vitest/coverage-v8": "^3.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -107,7 +107,7 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.4.3",
|
||||
"lint-staged": "^15.4.1",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
|
||||
107
frontend/src/api/github-axios-instance.ts
Normal file
107
frontend/src/api/github-axios-instance.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
const github = axios.create({
|
||||
baseURL: "https://api.github.com",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
const setAuthTokenHeader = (token: string) => {
|
||||
github.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
};
|
||||
|
||||
const removeAuthTokenHeader = () => {
|
||||
if (github.defaults.headers.common.Authorization) {
|
||||
delete github.defaults.headers.common.Authorization;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if response has attributes to perform refresh
|
||||
*/
|
||||
const canRefresh = (error: unknown): boolean =>
|
||||
!!(
|
||||
error instanceof AxiosError &&
|
||||
error.config &&
|
||||
error.response &&
|
||||
error.response.status
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if the data is a GitHub error response
|
||||
* @param data The data to check
|
||||
* @returns Boolean indicating if the data is a GitHub error response
|
||||
*/
|
||||
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
|
||||
data: T | GitHubErrorReponse | null,
|
||||
): data is GitHubErrorReponse =>
|
||||
!!data && "message" in data && data.message !== undefined;
|
||||
|
||||
// Axios interceptor to handle token refresh
|
||||
const setupAxiosInterceptors = (
|
||||
appMode: string,
|
||||
refreshToken: () => Promise<boolean>,
|
||||
logout: () => void,
|
||||
) => {
|
||||
github.interceptors.response.use(
|
||||
// Pass successful responses through
|
||||
(response) => {
|
||||
const parsedData = response.data;
|
||||
if (isGitHubErrorReponse(parsedData)) {
|
||||
const error = new AxiosError(
|
||||
"Failed",
|
||||
"",
|
||||
response.config,
|
||||
response.request,
|
||||
response,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
// Retry request exactly once if token is expired
|
||||
async (error) => {
|
||||
if (!canRefresh(error)) {
|
||||
return Promise.reject(new Error("Failed to refresh token"));
|
||||
}
|
||||
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Check if the error is due to an expired token
|
||||
if (
|
||||
error.response.status === 401 &&
|
||||
!originalRequest._retry // Prevent infinite retry loops
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
if (appMode === "saas") {
|
||||
try {
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
return await github(originalRequest);
|
||||
}
|
||||
|
||||
logout();
|
||||
return await Promise.reject(new Error("Failed to refresh token"));
|
||||
} catch (refreshError) {
|
||||
// If token refresh fails, evict the user
|
||||
logout();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the error is not due to an expired token, propagate the error
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
github,
|
||||
setAuthTokenHeader,
|
||||
removeAuthTokenHeader,
|
||||
setupAxiosInterceptors,
|
||||
};
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
GetTrajectoryResponse,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/types/settings";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
|
||||
class OpenHands {
|
||||
/**
|
||||
@@ -154,6 +154,25 @@ class OpenHands {
|
||||
return response.status === 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Github Token
|
||||
* @returns Refreshed Github access token
|
||||
*/
|
||||
static async refreshToken(
|
||||
appMode: GetConfigResponse["APP_MODE"],
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
if (appMode === "oss") return "";
|
||||
|
||||
const response = await openHands.post<GitHubAccessTokenResponse>(
|
||||
"/api/refresh-token",
|
||||
{
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return response.data.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob of the workspace zip
|
||||
* @returns Blob of the workspace zip
|
||||
@@ -223,11 +242,13 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
githubToken?: string,
|
||||
selectedRepository?: string,
|
||||
initialUserMsg?: string,
|
||||
imageUrls?: string[],
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
github_token: githubToken,
|
||||
selected_repository: selectedRepository,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
@@ -347,10 +368,6 @@ class OpenHands {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async logout(): Promise<void> {
|
||||
await openHands.post("/api/logout");
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -2,9 +2,9 @@ import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { DownloadModal } from "#/components/shared/download-modal";
|
||||
import type { RootState } from "#/store";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
interface ActionSuggestionsProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -13,7 +13,7 @@ interface ActionSuggestionsProps {
|
||||
export function ActionSuggestions({
|
||||
onSuggestionsClick,
|
||||
}: ActionSuggestionsProps) {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
@@ -32,7 +32,7 @@ export function ActionSuggestions({
|
||||
onClose={handleDownloadClose}
|
||||
isOpen={isDownloading}
|
||||
/>
|
||||
{githubTokenIsSet && selectedRepository ? (
|
||||
{gitHubToken && selectedRepository ? (
|
||||
<div className="flex flex-row gap-2 justify-center w-full">
|
||||
{!hasPullRequest ? (
|
||||
<>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ChatMessage({
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
<Markdown
|
||||
className="text-sm overflow-auto break-words"
|
||||
className="text-sm overflow-auto"
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
|
||||
@@ -5,12 +5,14 @@ import { SuggestionBox } from "#/components/features/suggestions/suggestion-box"
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import { GitHubRepositorySelector } from "./github-repo-selector";
|
||||
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
|
||||
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
|
||||
interface GitHubRepositoriesSuggestionBoxProps {
|
||||
handleSubmit: () => void;
|
||||
@@ -49,7 +51,7 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
}
|
||||
};
|
||||
|
||||
const isLoggedIn = !!user;
|
||||
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -74,9 +76,11 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
}
|
||||
/>
|
||||
{connectToGitHubModalOpen && (
|
||||
<AccountSettingsModal
|
||||
onClose={() => setConnectToGitHubModalOpen(false)}
|
||||
/>
|
||||
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
|
||||
<ConnectToGitHubModal
|
||||
onClose={() => setConnectToGitHubModalOpen(false)}
|
||||
/>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { FaListUl } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { UserActions } from "./user-actions";
|
||||
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
|
||||
import { DocsButton } from "#/components/shared/buttons/docs-button";
|
||||
@@ -20,17 +21,20 @@ import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
export function Sidebar() {
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
const user = useGitHubUser();
|
||||
const { data: config } = useConfig();
|
||||
const { data: settings, isError: settingsError } = useSettings();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { logout } = useAuth();
|
||||
const {
|
||||
data: settings,
|
||||
isError: settingsIsError,
|
||||
isSuccess: settingsSuccessfulyFetched,
|
||||
} = useSettings();
|
||||
|
||||
const { isUpToDate: settingsAreUpToDate } = useCurrentSettings();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
@@ -52,14 +56,15 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
const handleAccountSettingsModalClose = () => {
|
||||
// If the user closes the modal without connecting to GitHub,
|
||||
// we need to log them out to clear the invalid token from the
|
||||
// local storage
|
||||
if (user.isError) logout();
|
||||
setAccountSettingsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else await saveUserSettings({ unset_github_token: true });
|
||||
posthog.reset();
|
||||
};
|
||||
const showSettingsModal =
|
||||
isAuthed && (!settingsAreUpToDate || settingsModalIsOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -87,7 +92,7 @@ export function Sidebar() {
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
}
|
||||
onLogout={handleLogout}
|
||||
onLogout={logout}
|
||||
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -105,12 +110,13 @@ export function Sidebar() {
|
||||
{accountSettingsModalOpen && (
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{(settingsError || settingsModalIsOpen) && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{settingsIsError ||
|
||||
(showSettingsModal && settingsSuccessfulyFetched && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,14 +10,11 @@ import { TOSCheckbox } from "./tos-checkbox";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
|
||||
interface WaitlistModalProps {
|
||||
ghTokenIsSet: boolean;
|
||||
ghToken: string | null;
|
||||
githubAuthUrl: string | null;
|
||||
}
|
||||
|
||||
export function WaitlistModal({
|
||||
ghTokenIsSet,
|
||||
githubAuthUrl,
|
||||
}: WaitlistModalProps) {
|
||||
export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
|
||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
@@ -31,11 +28,11 @@ export function WaitlistModal({
|
||||
<ModalBackdrop>
|
||||
<ModalBody>
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<WaitlistMessage content={ghTokenIsSet ? "waitlist" : "sign-in"} />
|
||||
<WaitlistMessage content={ghToken ? "waitlist" : "sign-in"} />
|
||||
|
||||
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
|
||||
|
||||
{!ghTokenIsSet && (
|
||||
{!ghToken && (
|
||||
<ModalButton
|
||||
disabled={!isTosAccepted}
|
||||
text="Connect to GitHub"
|
||||
@@ -44,7 +41,7 @@ export function WaitlistModal({
|
||||
onClick={handleGitHubAuth}
|
||||
/>
|
||||
)}
|
||||
{ghTokenIsSet && <JoinWaitlistAnchor />}
|
||||
{ghToken && <JoinWaitlistAnchor />}
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
@@ -8,13 +7,13 @@ import {
|
||||
import { ModalBody } from "../modal-body";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
import { CustomInput } from "../../custom-input";
|
||||
import { FormFieldset } from "../../form-fieldset";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { GitHubTokenInput } from "./github-token-input";
|
||||
import { PostSettings } from "#/types/settings";
|
||||
|
||||
interface AccountSettingsFormProps {
|
||||
onClose: () => void;
|
||||
@@ -29,12 +28,11 @@ export function AccountSettingsForm({
|
||||
gitHubError,
|
||||
analyticsConsent,
|
||||
}: AccountSettingsFormProps) {
|
||||
const { gitHubToken, setGitHubToken, logout } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { saveUserSettings, settings } = useCurrentSettings();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET;
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
@@ -43,9 +41,7 @@ export function AccountSettingsForm({
|
||||
const language = formData.get("language")?.toString();
|
||||
const analytics = formData.get("analytics")?.toString() === "on";
|
||||
|
||||
const newSettings: Partial<PostSettings> = {};
|
||||
|
||||
if (ghToken) newSettings.github_token = ghToken;
|
||||
if (ghToken) setGitHubToken(ghToken);
|
||||
|
||||
// The form returns the language label, so we need to find the corresponding
|
||||
// language key to save it in the settings
|
||||
@@ -54,11 +50,9 @@ export function AccountSettingsForm({
|
||||
({ label }) => label === language,
|
||||
)?.value;
|
||||
|
||||
if (languageKey) newSettings.LANGUAGE = languageKey;
|
||||
if (languageKey) await saveUserSettings({ LANGUAGE: languageKey });
|
||||
}
|
||||
|
||||
await saveUserSettings(newSettings);
|
||||
|
||||
handleCaptureConsent(analytics);
|
||||
const ANALYTICS = analytics.toString();
|
||||
localStorage.setItem("analytics-consent", ANALYTICS);
|
||||
@@ -66,12 +60,6 @@ export function AccountSettingsForm({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onDisconnect = async () => {
|
||||
await saveUserSettings({ unset_github_token: true });
|
||||
posthog.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBody testID="account-settings-form">
|
||||
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
|
||||
@@ -101,20 +89,23 @@ export function AccountSettingsForm({
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
<>
|
||||
<GitHubTokenInput githubTokenIsSet={githubTokenIsSet} />
|
||||
{!githubTokenIsSet && (
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.COMMON$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
)}
|
||||
<CustomInput
|
||||
name="ghToken"
|
||||
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.COMMON$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
</>
|
||||
)}
|
||||
{gitHubError && (
|
||||
@@ -122,12 +113,14 @@ export function AccountSettingsForm({
|
||||
{t(I18nKey.GITHUB$TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{githubTokenIsSet && !gitHubError && (
|
||||
{gitHubToken && !gitHubError && (
|
||||
<ModalButton
|
||||
testId="disconnect-github"
|
||||
variant="text-like"
|
||||
text={t(I18nKey.BUTTON$DISCONNECT)}
|
||||
onClick={onDisconnect}
|
||||
onClick={() => {
|
||||
logout();
|
||||
onClose();
|
||||
}}
|
||||
className="text-danger self-start"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCheckCircle } from "react-icons/fa";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface GitHubTokenInputProps {
|
||||
githubTokenIsSet: boolean;
|
||||
}
|
||||
|
||||
export function GitHubTokenInput({ githubTokenIsSet }: GitHubTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label htmlFor="ghToken" className="flex flex-col gap-2">
|
||||
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3] flex items-center gap-1">
|
||||
{githubTokenIsSet && (
|
||||
<FaCheckCircle
|
||||
data-testid="github-token-set-checkmark"
|
||||
size={12}
|
||||
className="text-[#00D1B2]"
|
||||
/>
|
||||
)}
|
||||
{t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
<span className="text-[#A3A3A3]">
|
||||
{" "}
|
||||
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
|
||||
</span>
|
||||
</span>
|
||||
{!githubTokenIsSet && (
|
||||
<input
|
||||
data-testid="github-token-input"
|
||||
id="ghToken"
|
||||
name="ghToken"
|
||||
type="password"
|
||||
className="bg-[#27272A] text-xs py-[10px] px-3 rounded"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBody } from "./modal-body";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
} from "./confirmation-modals/base-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { ModalButton } from "../buttons/modal-button";
|
||||
import { CustomInput } from "../custom-input";
|
||||
|
||||
interface ConnectToGitHubModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
const { gitHubToken, setGitHubToken } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const ghToken = formData.get("ghToken")?.toString();
|
||||
|
||||
if (ghToken) setGitHubToken(ghToken);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-2 self-start">
|
||||
<BaseModalTitle title="Connect to GitHub" />
|
||||
<BaseModalDescription
|
||||
description={
|
||||
<span>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="w-full flex flex-col gap-6">
|
||||
<CustomInput
|
||||
label="GitHub Token"
|
||||
name="ghToken"
|
||||
required
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
testId="connect-to-github"
|
||||
type="submit"
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)}
|
||||
className="bg-[#791B80] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
onClick={onClose}
|
||||
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CLOSE)}
|
||||
className="bg-[#737373] w-full"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { getDefaultSettings } from "#/services/settings";
|
||||
import { getDefaultSettings, Settings } from "#/services/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
|
||||
import { DangerModal } from "../confirmation-modals/danger-modal";
|
||||
import { extractSettings } from "#/utils/settings-utils";
|
||||
import { extractSettings, saveSettingsView } from "#/utils/settings-utils";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
import { AdvancedOptionSwitch } from "../../inputs/advanced-option-switch";
|
||||
@@ -23,8 +23,6 @@ import { ModelSelector } from "./model-selector";
|
||||
import { RuntimeSizeSelector } from "./runtime-size-selector";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { MEMORY_CONDENSER } from "#/utils/feature-flags";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@@ -66,14 +64,12 @@ export function SettingsForm({
|
||||
const isUsingConfirmationMode = !!settings.CONFIRMATION_MODE;
|
||||
const isUsingBaseUrl = !!settings.LLM_BASE_URL;
|
||||
const isUsingCustomModel = !!settings.LLM_MODEL && !isKnownModel;
|
||||
const isUsingDefaultCondenser = !!settings.ENABLE_DEFAULT_CONDENSER;
|
||||
|
||||
return (
|
||||
isUsingSecurityAnalyzer ||
|
||||
isUsingConfirmationMode ||
|
||||
isUsingBaseUrl ||
|
||||
isUsingCustomModel ||
|
||||
isUsingDefaultCondenser
|
||||
isUsingCustomModel
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,11 +90,11 @@ export function SettingsForm({
|
||||
};
|
||||
|
||||
const handleFormSubmission = async (formData: FormData) => {
|
||||
const keys = Array.from(formData.keys());
|
||||
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
|
||||
const newSettings = extractSettings(formData);
|
||||
|
||||
// Inject the condenser config from the current feature flag value
|
||||
newSettings.ENABLE_DEFAULT_CONDENSER = MEMORY_CONDENSER;
|
||||
|
||||
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
|
||||
await saveUserSettings(newSettings);
|
||||
onClose();
|
||||
resetOngoingSession();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "../../loading-spinner";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { SettingsForm } from "./settings-form";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
interface SettingsModalProps {
|
||||
settings: Settings;
|
||||
|
||||
@@ -1,21 +1,110 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
|
||||
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
|
||||
} from "#/api/open-hands-axios";
|
||||
import {
|
||||
setAuthTokenHeader as setGitHubAuthTokenHeader,
|
||||
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
|
||||
setupAxiosInterceptors as setupGithubAxiosInterceptors,
|
||||
} from "#/api/github-axios-instance";
|
||||
|
||||
interface AuthContextType {
|
||||
githubTokenIsSet: boolean;
|
||||
setGitHubTokenIsSet: (value: boolean) => void;
|
||||
gitHubToken: string | null;
|
||||
setUserId: (userId: string) => void;
|
||||
setGitHubToken: (token: string | null) => void;
|
||||
clearGitHubToken: () => void;
|
||||
refreshToken: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(false);
|
||||
const [gitHubTokenState, setGitHubTokenState] = React.useState<string | null>(
|
||||
() => localStorage.getItem("ghToken"),
|
||||
);
|
||||
|
||||
const [userIdState, setUserIdState] = React.useState<string>(
|
||||
() => localStorage.getItem("userId") || "",
|
||||
);
|
||||
|
||||
const clearGitHubToken = () => {
|
||||
setGitHubTokenState(null);
|
||||
setUserIdState("");
|
||||
localStorage.removeItem("ghToken");
|
||||
localStorage.removeItem("userId");
|
||||
|
||||
removeOpenHandsGitHubTokenHeader();
|
||||
removeGitHubAuthTokenHeader();
|
||||
};
|
||||
|
||||
const setGitHubToken = (token: string | null) => {
|
||||
setGitHubTokenState(token);
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("ghToken", token);
|
||||
setOpenHandsGitHubTokenHeader(token);
|
||||
setGitHubAuthTokenHeader(token);
|
||||
} else {
|
||||
clearGitHubToken();
|
||||
}
|
||||
};
|
||||
|
||||
const setUserId = (userId: string) => {
|
||||
setUserIdState(userIdState);
|
||||
localStorage.setItem("userId", userId);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearGitHubToken();
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
const refreshToken = async (): Promise<boolean> => {
|
||||
const config = await OpenHands.getConfig();
|
||||
|
||||
if (config.APP_MODE !== "saas" || !gitHubTokenState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newToken = await OpenHands.refreshToken(config.APP_MODE, userIdState);
|
||||
if (newToken) {
|
||||
setGitHubToken(newToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearGitHubToken();
|
||||
return false;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const storedGitHubToken = localStorage.getItem("ghToken");
|
||||
|
||||
const userId = localStorage.getItem("userId") || "";
|
||||
|
||||
setGitHubToken(storedGitHubToken);
|
||||
setUserId(userId);
|
||||
const setupIntercepter = async () => {
|
||||
const config = await OpenHands.getConfig();
|
||||
setupGithubAxiosInterceptors(config.APP_MODE, refreshToken, logout);
|
||||
};
|
||||
|
||||
setupIntercepter();
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
githubTokenIsSet,
|
||||
setGitHubTokenIsSet,
|
||||
gitHubToken: gitHubTokenState,
|
||||
setGitHubToken,
|
||||
setUserId,
|
||||
clearGitHubToken,
|
||||
refreshToken,
|
||||
logout,
|
||||
}),
|
||||
[githubTokenIsSet, setGitHubTokenIsSet],
|
||||
[gitHubTokenState],
|
||||
);
|
||||
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import React from "react";
|
||||
import {
|
||||
LATEST_SETTINGS_VERSION,
|
||||
Settings,
|
||||
settingsAreUpToDate,
|
||||
} from "#/services/settings";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { PostSettings, Settings } from "#/types/settings";
|
||||
|
||||
interface SettingsContextType {
|
||||
saveUserSettings: (newSettings: Partial<PostSettings>) => Promise<void>;
|
||||
isUpToDate: boolean;
|
||||
setIsUpToDate: (value: boolean) => void;
|
||||
saveUserSettings: (newSettings: Partial<Settings>) => Promise<void>;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
@@ -20,8 +26,10 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
const { data: userSettings } = useSettings();
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
|
||||
const saveUserSettings = async (newSettings: Partial<PostSettings>) => {
|
||||
const updatedSettings: Partial<PostSettings> = {
|
||||
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
|
||||
|
||||
const saveUserSettings = async (newSettings: Partial<Settings>) => {
|
||||
const updatedSettings: Partial<Settings> = {
|
||||
...userSettings,
|
||||
...newSettings,
|
||||
};
|
||||
@@ -30,15 +38,27 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
delete updatedSettings.LLM_API_KEY;
|
||||
}
|
||||
|
||||
await saveSettings(updatedSettings);
|
||||
await saveSettings(updatedSettings, {
|
||||
onSuccess: () => {
|
||||
if (!isUpToDate) {
|
||||
localStorage.setItem(
|
||||
"SETTINGS_VERSION",
|
||||
LATEST_SETTINGS_VERSION.toString(),
|
||||
);
|
||||
setIsUpToDate(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
isUpToDate,
|
||||
setIsUpToDate,
|
||||
saveUserSettings,
|
||||
settings: userSettings,
|
||||
}),
|
||||
[saveUserSettings, userSettings],
|
||||
[isUpToDate, setIsUpToDate, saveUserSettings, userSettings],
|
||||
);
|
||||
|
||||
return <SettingsContext value={value}>{children}</SettingsContext>;
|
||||
|
||||
@@ -5,10 +5,12 @@ import { useDispatch, useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { setInitialPrompt } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { gitHubToken } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedRepository, files, importedProjectZip } = useSelector(
|
||||
@@ -29,6 +31,7 @@ export const useCreateConversation = () => {
|
||||
if (variables.q) dispatch(setInitialPrompt(variables.q));
|
||||
|
||||
return OpenHands.createConversation(
|
||||
gitHubToken || undefined,
|
||||
selectedRepository || undefined,
|
||||
variables.q,
|
||||
files,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useLogout = () => {
|
||||
const { setGitHubTokenIsSet } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: OpenHands.logout,
|
||||
onSuccess: async () => {
|
||||
setGitHubTokenIsSet(false);
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { ApiSettings, DEFAULT_SETTINGS, Settings } from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { PostSettings, PostApiSettings } from "#/types/settings";
|
||||
|
||||
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
const apiSettings: Partial<PostApiSettings> = {
|
||||
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
|
||||
const apiSettings: Partial<ApiSettings> = {
|
||||
llm_model: settings.LLM_MODEL,
|
||||
llm_base_url: settings.LLM_BASE_URL,
|
||||
agent: settings.AGENT || DEFAULT_SETTINGS.AGENT,
|
||||
@@ -12,10 +11,6 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
confirmation_mode: settings.CONFIRMATION_MODE,
|
||||
security_analyzer: settings.SECURITY_ANALYZER,
|
||||
llm_api_key: settings.LLM_API_KEY?.trim() || undefined,
|
||||
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
github_token: settings.github_token,
|
||||
unset_github_token: settings.unset_github_token,
|
||||
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
|
||||
};
|
||||
|
||||
await OpenHands.saveSettings(apiSettings);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useAppInstallations = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", githubTokenIsSet, config?.GITHUB_CLIENT_ID],
|
||||
queryKey: ["installations", gitHubToken, config?.GITHUB_CLIENT_ID],
|
||||
queryFn: OpenHands.getGitHubUserInstallationIds,
|
||||
enabled:
|
||||
githubTokenIsSet &&
|
||||
!!gitHubToken &&
|
||||
!!config?.GITHUB_CLIENT_ID &&
|
||||
config?.APP_MODE === "saas",
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { retrieveGitHubAppRepositories } from "#/api/github";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useAppInstallations } from "./use-app-installations";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useAppRepositories = () => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { data: installations } = useAppInstallations();
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", githubTokenIsSet, installations],
|
||||
queryKey: ["repositories", gitHubToken, installations],
|
||||
queryFn: async ({
|
||||
pageParam,
|
||||
}: {
|
||||
@@ -46,7 +46,7 @@ export const useAppRepositories = () => {
|
||||
return null;
|
||||
},
|
||||
enabled:
|
||||
githubTokenIsSet &&
|
||||
!!gitHubToken &&
|
||||
Array.isArray(installations) &&
|
||||
installations.length > 0 &&
|
||||
config?.APP_MODE === "saas",
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useLogout } from "../mutation/use-logout";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
|
||||
export const useGitHubUser = () => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { setGitHubTokenIsSet } = useAuth();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
const { gitHubToken, setUserId, logout } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const user = useQuery({
|
||||
queryKey: ["user", githubTokenIsSet],
|
||||
queryKey: ["user", gitHubToken],
|
||||
queryFn: OpenHands.getGitHubUser,
|
||||
enabled: githubTokenIsSet && !!config?.APP_MODE,
|
||||
enabled: !!gitHubToken && !!config?.APP_MODE,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user.data) {
|
||||
setUserId(user.data.id.toString());
|
||||
posthog.identify(user.data.login, {
|
||||
company: user.data.company,
|
||||
name: user.data.name,
|
||||
@@ -33,18 +29,9 @@ export const useGitHubUser = () => {
|
||||
}
|
||||
}, [user.data]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else {
|
||||
await saveUserSettings({ unset_github_token: true });
|
||||
setGitHubTokenIsSet(false);
|
||||
}
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user.isError) {
|
||||
handleLogout();
|
||||
logout();
|
||||
}
|
||||
}, [user.isError]);
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useIsAuthed = () => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "authenticated", githubTokenIsSet, appMode],
|
||||
queryKey: ["user", "authenticated", gitHubToken, appMode],
|
||||
queryFn: () => OpenHands.authenticate(appMode!),
|
||||
enabled: !!appMode,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { AxiosError } from "axios";
|
||||
import { DEFAULT_SETTINGS, getLocalStorageSettings } from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
const getSettingsQueryFn = async () => {
|
||||
const apiSettings = await OpenHands.getSettings();
|
||||
try {
|
||||
const apiSettings = await OpenHands.getSettings();
|
||||
|
||||
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: apiSettings.llm_api_key,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
|
||||
GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set,
|
||||
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
|
||||
};
|
||||
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: apiSettings.llm_api_key,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
apiSettings.remote_runtime_resource_factor,
|
||||
};
|
||||
}
|
||||
|
||||
return getLocalStorageSettings();
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 404) {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const useSettings = () => {
|
||||
const { setGitHubTokenIsSet } = useAuth();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettingsQueryFn,
|
||||
initialData: DEFAULT_SETTINGS,
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -39,9 +47,5 @@ export const useSettings = () => {
|
||||
}
|
||||
}, [query.data?.LLM_API_KEY]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET);
|
||||
}, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { retrieveGitHubUserRepositories } from "#/api/github";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
|
||||
export const useUserRepositories = () => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", githubTokenIsSet],
|
||||
queryKey: ["repositories", gitHubToken],
|
||||
queryFn: async ({ pageParam }) =>
|
||||
retrieveGitHubUserRepositories(pageParam, 100),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
enabled: githubTokenIsSet && config?.APP_MODE === "oss",
|
||||
enabled: !!gitHubToken && config?.APP_MODE === "oss",
|
||||
});
|
||||
|
||||
// TODO: Once we create our custom dropdown component, we should fetch data onEndReached
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import React from "react";
|
||||
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
interface UseGitHubAuthUrlConfig {
|
||||
gitHubToken: string | null;
|
||||
appMode: GetConfigResponse["APP_MODE"] | null;
|
||||
gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null;
|
||||
}
|
||||
|
||||
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (config.appMode === "saas" && !githubTokenIsSet)
|
||||
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) =>
|
||||
React.useMemo(() => {
|
||||
if (config.appMode === "saas" && !config.gitHubToken)
|
||||
return generateGitHubAuthUrl(
|
||||
config.gitHubClientId || "",
|
||||
new URL(window.location.href),
|
||||
);
|
||||
|
||||
return null;
|
||||
}, [githubTokenIsSet, config.appMode, config.gitHubClientId]);
|
||||
};
|
||||
}, [config.gitHubToken, config.appMode, config.gitHubClientId]);
|
||||
|
||||
52
frontend/src/hooks/use-maybe-migrate-settings.ts
Normal file
52
frontend/src/hooks/use-maybe-migrate-settings.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Sometimes we ship major changes, like a new default agent.
|
||||
|
||||
import React from "react";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import {
|
||||
getCurrentSettingsVersion,
|
||||
DEFAULT_SETTINGS,
|
||||
getLocalStorageSettings,
|
||||
} from "#/services/settings";
|
||||
import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
|
||||
// In this case, we may want to override a previous choice made by the user.
|
||||
export const useMaybeMigrateSettings = () => {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const { isUpToDate } = useCurrentSettings();
|
||||
|
||||
const maybeMigrateSettings = async () => {
|
||||
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) {
|
||||
// We used to log out here, but it's breaking things
|
||||
}
|
||||
|
||||
// Only save settings if user already previously saved settings
|
||||
// That way we avoid setting defaults for new users too early
|
||||
if (currentVersion !== 0 && currentVersion < 5) {
|
||||
const localSettings = getLocalStorageSettings();
|
||||
await saveSettings(localSettings);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isUpToDate) {
|
||||
maybeMigrateSettings();
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
@@ -4506,8 +4506,5 @@
|
||||
"fr": "Que voulez-vous construire ?",
|
||||
"tr": "Ne inşa etmek istiyorsun?",
|
||||
"de": "Was möchten Sie erstellen?"
|
||||
},
|
||||
"SETTINGS_FORM$ENABLE_DEFAULT_CONDENSER_SWITCH_LABEL": {
|
||||
"en": "Enable Memory Condenser"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,17 @@ import {
|
||||
ResultSet,
|
||||
} from "#/api/open-hands.types";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { ApiSettings, PostApiSettings } from "#/types/settings";
|
||||
|
||||
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
|
||||
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
|
||||
agent: DEFAULT_SETTINGS.AGENT,
|
||||
language: DEFAULT_SETTINGS.LANGUAGE,
|
||||
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
|
||||
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
remote_runtime_resource_factor:
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
github_token_is_set: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET,
|
||||
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
|
||||
};
|
||||
|
||||
const MOCK_USER_PREFERENCES: {
|
||||
settings: ApiSettings | PostApiSettings;
|
||||
} = {
|
||||
settings: MOCK_DEFAULT_USER_SETTINGS,
|
||||
export const MOCK_USER_PREFERENCES = {
|
||||
settings: {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
|
||||
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
|
||||
agent: DEFAULT_SETTINGS.AGENT,
|
||||
language: DEFAULT_SETTINGS.LANGUAGE,
|
||||
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
|
||||
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
},
|
||||
};
|
||||
|
||||
const conversations: Conversation[] = [
|
||||
@@ -177,32 +168,17 @@ export const handlers = [
|
||||
|
||||
return HttpResponse.json(config);
|
||||
}),
|
||||
http.get("/api/settings", async () => {
|
||||
const settings: ApiSettings = {
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
};
|
||||
// @ts-expect-error - mock types
|
||||
if (settings.github_token) settings.github_token_is_set = true;
|
||||
|
||||
return HttpResponse.json(settings);
|
||||
}),
|
||||
http.get("/api/settings", async () =>
|
||||
HttpResponse.json(MOCK_USER_PREFERENCES.settings),
|
||||
),
|
||||
http.post("/api/settings", async ({ request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (body) {
|
||||
let newSettings: Partial<PostApiSettings> = {};
|
||||
if (typeof body === "object") {
|
||||
newSettings = { ...body };
|
||||
if (newSettings.unset_github_token) {
|
||||
newSettings.github_token = undefined;
|
||||
newSettings.github_token_is_set = false;
|
||||
delete newSettings.unset_github_token;
|
||||
}
|
||||
}
|
||||
|
||||
MOCK_USER_PREFERENCES.settings = {
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
...newSettings,
|
||||
// @ts-expect-error - We know this is a settings object
|
||||
...body,
|
||||
};
|
||||
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { QueryClientConfig, QueryCache } from "@tanstack/react-query";
|
||||
import { renderToastIfError } from "./utils/render-toast-if-error";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts", "settings"];
|
||||
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
|
||||
|
||||
export const queryClientConfig: QueryClientConfig = {
|
||||
queryCache: new QueryCache({
|
||||
onError: (error, query) => {
|
||||
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
|
||||
renderToastIfError(error);
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -18,7 +18,7 @@ export const queryClientConfig: QueryClientConfig = {
|
||||
},
|
||||
mutations: {
|
||||
onError: (error) => {
|
||||
renderToastIfError(error);
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { ImportProjectSuggestionBox } from "../../components/features/suggestions/import-project-suggestion-box";
|
||||
import { GitHubRepositoriesSuggestionBox } from "#/components/features/github/github-repositories-suggestion-box";
|
||||
import { HeroHeading } from "#/components/shared/hero-heading";
|
||||
@@ -15,6 +16,7 @@ import { TaskForm } from "#/components/shared/task-form";
|
||||
|
||||
function Home() {
|
||||
const { t } = useTranslation();
|
||||
const { gitHubToken } = useAuth();
|
||||
const dispatch = useDispatch();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
@@ -22,6 +24,7 @@ function Home() {
|
||||
const { data: user } = useGitHubUser();
|
||||
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
gitHubToken,
|
||||
appMode: config?.APP_MODE || null,
|
||||
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { FilesProvider } from "#/context/files";
|
||||
import { ChatInterface } from "../../components/features/chat/chat-interface";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { EventHandler } from "./event-handler";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
||||
import { Container } from "#/components/layout/container";
|
||||
import {
|
||||
@@ -41,6 +42,7 @@ import { RootState } from "#/store";
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
const { t } = useTranslation();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: settings } = useSettings();
|
||||
const { conversationId } = useConversation();
|
||||
const { data: conversation, isFetched } = useUserConversation(
|
||||
@@ -55,9 +57,8 @@ function AppContent() {
|
||||
const [width, setWidth] = React.useState(window.innerWidth);
|
||||
|
||||
const secrets = React.useMemo(
|
||||
// secrets to filter go here
|
||||
() => [].filter((secret) => secret !== null),
|
||||
[],
|
||||
() => [gitHubToken].filter((secret) => secret !== null),
|
||||
[gitHubToken],
|
||||
);
|
||||
|
||||
const Terminal = React.useMemo(
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useRouteError, isRouteErrorResponse, Outlet } from "react-router";
|
||||
import i18n from "#/i18n";
|
||||
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
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 { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useMaybeMigrateSettings } from "#/hooks/use-maybe-migrate-settings";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -43,7 +44,9 @@ export function ErrorBoundary() {
|
||||
}
|
||||
|
||||
export default function MainApp() {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
useMaybeMigrateSettings();
|
||||
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
|
||||
@@ -51,13 +54,10 @@ export default function MainApp() {
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const {
|
||||
data: isAuthed,
|
||||
isFetching: isFetchingAuth,
|
||||
isError: authError,
|
||||
} = useIsAuthed();
|
||||
const { data: isAuthed, isFetching: isFetchingAuth } = useIsAuthed();
|
||||
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
gitHubToken,
|
||||
appMode: config.data?.APP_MODE || null,
|
||||
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
|
||||
});
|
||||
@@ -68,9 +68,8 @@ export default function MainApp() {
|
||||
}
|
||||
}, [settings?.LANGUAGE]);
|
||||
|
||||
const userIsAuthed = !!isAuthed && !authError;
|
||||
const renderWaitlistModal =
|
||||
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
|
||||
const isInWaitlist =
|
||||
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -86,11 +85,8 @@ export default function MainApp() {
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{renderWaitlistModal && (
|
||||
<WaitlistModal
|
||||
ghTokenIsSet={githubTokenIsSet}
|
||||
githubAuthUrl={gitHubAuthUrl}
|
||||
/>
|
||||
{isInWaitlist && (
|
||||
<WaitlistModal ghToken={gitHubToken} githubAuthUrl={gitHubAuthUrl} />
|
||||
)}
|
||||
|
||||
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (
|
||||
|
||||
@@ -2,13 +2,16 @@ import { useNavigate, useSearchParams } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
function OAuthGitHubCallback() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setGitHubToken } = useAuth();
|
||||
|
||||
const code = searchParams.get("code");
|
||||
|
||||
const { isSuccess, error } = useQuery({
|
||||
const { data, isSuccess, error } = useQuery({
|
||||
queryKey: ["access_token", code],
|
||||
queryFn: () => OpenHands.getGitHubAccessToken(code!),
|
||||
enabled: !!code,
|
||||
@@ -16,6 +19,7 @@ function OAuthGitHubCallback() {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSuccess) {
|
||||
setGitHubToken(data.access_token);
|
||||
navigate("/");
|
||||
}
|
||||
}, [isSuccess]);
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
export const LATEST_SETTINGS_VERSION = 5;
|
||||
|
||||
export type Settings = {
|
||||
LLM_MODEL: string;
|
||||
LLM_BASE_URL: string;
|
||||
AGENT: string;
|
||||
LANGUAGE: string;
|
||||
LLM_API_KEY: string | null;
|
||||
CONFIRMATION_MODE: boolean;
|
||||
SECURITY_ANALYZER: string;
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
llm_model: string;
|
||||
llm_base_url: string;
|
||||
agent: string;
|
||||
language: string;
|
||||
llm_api_key: string | null;
|
||||
confirmation_mode: boolean;
|
||||
security_analyzer: string;
|
||||
remote_runtime_resource_factor: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
LLM_MODEL: "anthropic/claude-3-5-sonnet-20241022",
|
||||
LLM_BASE_URL: "",
|
||||
@@ -11,11 +31,55 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
CONFIRMATION_MODE: false,
|
||||
SECURITY_ANALYZER: "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
|
||||
GITHUB_TOKEN_IS_SET: false,
|
||||
ENABLE_DEFAULT_CONDENSER: false,
|
||||
};
|
||||
|
||||
export const getCurrentSettingsVersion = () => {
|
||||
const settingsVersion = localStorage.getItem("SETTINGS_VERSION");
|
||||
if (!settingsVersion) return 0;
|
||||
try {
|
||||
return parseInt(settingsVersion, 10);
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const settingsAreUpToDate = () =>
|
||||
getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
|
||||
|
||||
// TODO: localStorage settings are deprecated. Remove this after 1/31/2025
|
||||
/**
|
||||
* Get the settings from local storage
|
||||
* @returns the settings from local storage
|
||||
* @deprecated
|
||||
*/
|
||||
export const getLocalStorageSettings = (): Settings => {
|
||||
const llmModel = localStorage.getItem("LLM_MODEL");
|
||||
const baseUrl = localStorage.getItem("LLM_BASE_URL");
|
||||
const agent = localStorage.getItem("AGENT");
|
||||
const language = localStorage.getItem("LANGUAGE");
|
||||
const llmApiKey = localStorage.getItem("LLM_API_KEY");
|
||||
const confirmationMode = localStorage.getItem("CONFIRMATION_MODE") === "true";
|
||||
const securityAnalyzer = localStorage.getItem("SECURITY_ANALYZER");
|
||||
|
||||
return {
|
||||
LLM_MODEL: llmModel || DEFAULT_SETTINGS.LLM_MODEL,
|
||||
LLM_BASE_URL: baseUrl || DEFAULT_SETTINGS.LLM_BASE_URL,
|
||||
AGENT: agent || DEFAULT_SETTINGS.AGENT,
|
||||
LANGUAGE: language || DEFAULT_SETTINGS.LANGUAGE,
|
||||
LLM_API_KEY: llmApiKey || DEFAULT_SETTINGS.LLM_API_KEY,
|
||||
CONFIRMATION_MODE: confirmationMode || DEFAULT_SETTINGS.CONFIRMATION_MODE,
|
||||
SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default settings
|
||||
*/
|
||||
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
|
||||
|
||||
/**
|
||||
* Get the current settings, either from local storage or defaults
|
||||
*/
|
||||
export const getSettings = (): Settings => getLocalStorageSettings();
|
||||
|
||||
8
frontend/src/types/react-query.d.ts
vendored
8
frontend/src/types/react-query.d.ts
vendored
@@ -1,8 +0,0 @@
|
||||
import "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
declare module "@tanstack/react-query" {
|
||||
interface Register {
|
||||
defaultError: AxiosError;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
export type Settings = {
|
||||
LLM_MODEL: string;
|
||||
LLM_BASE_URL: string;
|
||||
AGENT: string;
|
||||
LANGUAGE: string;
|
||||
LLM_API_KEY: string | null;
|
||||
CONFIRMATION_MODE: boolean;
|
||||
SECURITY_ANALYZER: string;
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
|
||||
GITHUB_TOKEN_IS_SET: boolean;
|
||||
ENABLE_DEFAULT_CONDENSER: boolean;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
llm_model: string;
|
||||
llm_base_url: string;
|
||||
agent: string;
|
||||
language: string;
|
||||
llm_api_key: string | null;
|
||||
confirmation_mode: boolean;
|
||||
security_analyzer: string;
|
||||
remote_runtime_resource_factor: number;
|
||||
github_token_is_set: boolean;
|
||||
enable_default_condenser: boolean;
|
||||
};
|
||||
|
||||
export type PostSettings = Settings & {
|
||||
github_token: string;
|
||||
unset_github_token: boolean;
|
||||
};
|
||||
|
||||
export type PostApiSettings = ApiSettings & {
|
||||
github_token: string;
|
||||
unset_github_token: boolean;
|
||||
};
|
||||
@@ -13,4 +13,3 @@ function loadFeatureFlag(
|
||||
}
|
||||
|
||||
export const MULTI_CONVERSATION_UI = loadFeatureFlag("MULTI_CONVERSATION_UI");
|
||||
export const MEMORY_CONDENSER = loadFeatureFlag("MEMORY_CONDENSER");
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { AxiosError } from "axios";
|
||||
import toast from "react-hot-toast";
|
||||
import { isAxiosErrorWithResponse } from "./type-guards";
|
||||
|
||||
/**
|
||||
* Renders a toast with the error message from an Axios error
|
||||
* @param error The error to render a toast for
|
||||
*/
|
||||
export const renderToastIfError = (error: AxiosError) => {
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
if (isAxiosErrorWithResponse(error) && error.response?.data.error) {
|
||||
errorMessage = error.response?.data.error;
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
toast.error(errorMessage || "An error occurred");
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Settings } from "#/types/settings";
|
||||
import { Settings } from "#/services/settings";
|
||||
|
||||
const extractBasicFormData = (formData: FormData) => {
|
||||
const provider = formData.get("llm-provider")?.toString();
|
||||
@@ -44,7 +44,7 @@ const extractAdvancedFormData = (formData: FormData) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const extractSettings = (formData: FormData): Partial<Settings> => {
|
||||
const extractSettings = (formData: FormData): Partial<Settings> => {
|
||||
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
|
||||
extractBasicFormData(formData);
|
||||
|
||||
@@ -65,3 +65,12 @@ export const extractSettings = (formData: FormData): Partial<Settings> => {
|
||||
SECURITY_ANALYZER,
|
||||
};
|
||||
};
|
||||
|
||||
const saveSettingsView = (view: "basic" | "advanced") => {
|
||||
localStorage.setItem(
|
||||
"use-advanced-options",
|
||||
view === "advanced" ? "true" : "false",
|
||||
);
|
||||
};
|
||||
|
||||
export { extractSettings, saveSettingsView };
|
||||
|
||||
11
frontend/src/utils/storage.tsx
Normal file
11
frontend/src/utils/storage.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
const getCachedConfig = (): { [key: string]: string } => {
|
||||
const config = localStorage.getItem("ALL_SETTINGS");
|
||||
if (config === null || config === undefined) return {};
|
||||
try {
|
||||
return JSON.parse(config);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export { getCachedConfig };
|
||||
@@ -1,9 +0,0 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
export const isAxiosErrorWithResponse = (
|
||||
error: AxiosError,
|
||||
): error is AxiosError<{ error: string }> =>
|
||||
typeof error.response?.data === "object" &&
|
||||
error.response?.data !== null &&
|
||||
"error" in error.response.data &&
|
||||
typeof error.response?.data?.error === "string";
|
||||
@@ -34,6 +34,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("FEATURE_MULTI_CONVERSATION_UI", "true");
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "5");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "5");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "4");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ type: repo
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
|
||||
This repository contains the code for runtime-API, an automated AI software engineer. It has a Python backend
|
||||
(in the `openhands` directory) and React frontend (in the `frontend` directory).
|
||||
|
||||
## General Setup:
|
||||
|
||||
@@ -433,6 +433,26 @@ class CodeActAgent(Agent):
|
||||
],
|
||||
)
|
||||
]
|
||||
example_message = self.prompt_manager.get_example_user_message()
|
||||
if example_message:
|
||||
messages.append(
|
||||
Message(
|
||||
role='user',
|
||||
content=[TextContent(text=example_message)],
|
||||
cache_prompt=self.llm.is_caching_prompt_active(),
|
||||
)
|
||||
)
|
||||
|
||||
# Repository and runtime info
|
||||
additional_info = self.prompt_manager.get_additional_info()
|
||||
if self.config.enable_prompt_extensions and additional_info:
|
||||
# only add these if prompt extension is enabled
|
||||
messages.append(
|
||||
Message(
|
||||
role='user',
|
||||
content=[TextContent(text=additional_info)],
|
||||
)
|
||||
)
|
||||
|
||||
pending_tool_call_action_messages: dict[str, Message] = {}
|
||||
tool_call_id_to_message: dict[str, Message] = {}
|
||||
@@ -440,7 +460,6 @@ class CodeActAgent(Agent):
|
||||
# Condense the events from the state.
|
||||
events = self.condenser.condensed_history(state)
|
||||
|
||||
is_first_message_handled = False
|
||||
for event in events:
|
||||
# create a regular message from an event
|
||||
if isinstance(event, Action):
|
||||
@@ -482,22 +501,11 @@ class CodeActAgent(Agent):
|
||||
for response_id in _response_ids_to_remove:
|
||||
pending_tool_call_action_messages.pop(response_id)
|
||||
|
||||
for msg in messages_to_add:
|
||||
if msg:
|
||||
if msg.role == 'user' and not is_first_message_handled:
|
||||
is_first_message_handled = True
|
||||
# compose the first user message with examples
|
||||
self.prompt_manager.add_examples_to_initial_message(msg)
|
||||
|
||||
# and/or repo/runtime info
|
||||
if self.config.enable_prompt_extensions:
|
||||
self.prompt_manager.add_info_to_initial_message(msg)
|
||||
|
||||
# enhance the user message with additional context based on keywords matched
|
||||
if msg.role == 'user':
|
||||
self.prompt_manager.enhance_message(msg)
|
||||
|
||||
messages.append(msg)
|
||||
for message in messages_to_add:
|
||||
if message:
|
||||
if message.role == 'user':
|
||||
self.prompt_manager.enhance_message(message)
|
||||
messages.append(message)
|
||||
|
||||
if self.llm.is_caching_prompt_active():
|
||||
# NOTE: this is only needed for anthropic
|
||||
@@ -505,7 +513,7 @@ class CodeActAgent(Agent):
|
||||
# https://github.com/anthropics/anthropic-quickstarts/blob/8f734fd08c425c6ec91ddd613af04ff87d70c5a0/computer-use-demo/computer_use_demo/loop.py#L241-L262
|
||||
breakpoints_remaining = 3 # remaining 1 for system/tool
|
||||
for message in reversed(messages):
|
||||
if message.role in ('user', 'tool'):
|
||||
if message.role == 'user' or message.role == 'tool':
|
||||
if breakpoints_remaining > 0:
|
||||
message.content[
|
||||
-1
|
||||
|
||||
@@ -12,10 +12,7 @@ from litellm import (
|
||||
ModelResponse,
|
||||
)
|
||||
|
||||
from openhands.core.exceptions import (
|
||||
FunctionCallNotExistsError,
|
||||
FunctionCallValidationError,
|
||||
)
|
||||
from openhands.core.exceptions import FunctionCallNotExistsError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
@@ -497,19 +494,15 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
f'Failed to parse tool call arguments: {tool_call.function.arguments}'
|
||||
) from e
|
||||
if tool_call.function.name == 'execute_bash':
|
||||
# this is an LLM error: add empty command to avoid breaking the tool call
|
||||
if 'command' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "command" in tool call {tool_call.function.name}'
|
||||
)
|
||||
arguments['command'] = ''
|
||||
# convert is_input to boolean
|
||||
is_input = arguments.get('is_input', 'false') == 'true'
|
||||
action = CmdRunAction(command=arguments['command'], is_input=is_input)
|
||||
if 'is_input' in arguments:
|
||||
arguments['is_input'] = arguments['is_input'] == 'true'
|
||||
action = CmdRunAction(**arguments)
|
||||
elif tool_call.function.name == 'execute_ipython_cell':
|
||||
if 'code' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "code" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = IPythonRunCellAction(code=arguments['code'])
|
||||
action = IPythonRunCellAction(**arguments)
|
||||
elif tool_call.function.name == 'delegate_to_browsing_agent':
|
||||
action = AgentDelegateAction(
|
||||
agent='BrowsingAgent',
|
||||
@@ -518,30 +511,8 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
elif tool_call.function.name == 'finish':
|
||||
action = AgentFinishAction()
|
||||
elif tool_call.function.name == 'edit_file':
|
||||
if 'path' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "path" in tool call {tool_call.function.name}'
|
||||
)
|
||||
if 'content' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "content" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = FileEditAction(
|
||||
path=arguments['path'],
|
||||
content=arguments['content'],
|
||||
start=arguments.get('start', 1),
|
||||
end=arguments.get('end', -1),
|
||||
)
|
||||
action = FileEditAction(**arguments)
|
||||
elif tool_call.function.name == 'str_replace_editor':
|
||||
if 'command' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "command" in tool call {tool_call.function.name}'
|
||||
)
|
||||
if 'path' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "path" in tool call {tool_call.function.name}'
|
||||
)
|
||||
|
||||
# We implement this in agent_skills, which can be used via Jupyter
|
||||
# convert tool_call.function.arguments to kwargs that can be passed to file_editor
|
||||
code = f'print(file_editor(**{arguments}))'
|
||||
@@ -563,16 +534,8 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
impl_source=FileEditSource.OH_ACI,
|
||||
)
|
||||
elif tool_call.function.name == 'browser':
|
||||
if 'code' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "code" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = BrowseInteractiveAction(browser_actions=arguments['code'])
|
||||
elif tool_call.function.name == 'web_read':
|
||||
if 'url' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "url" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = BrowseURLAction(url=arguments['url'])
|
||||
else:
|
||||
raise FunctionCallNotExistsError(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
|
||||
<IMPORTANT>
|
||||
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
|
||||
* You should start exploring the file system with your view command, unless you need to explore more deeply.
|
||||
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
|
||||
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
|
||||
* You MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
|
||||
</IMPORTANT>
|
||||
|
||||
@@ -37,7 +37,7 @@ class SandboxConfig(BaseModel):
|
||||
This should be a JSON string that will be parsed into a dictionary.
|
||||
"""
|
||||
|
||||
remote_runtime_api_url: str | None = Field(default='http://localhost:8000')
|
||||
remote_runtime_api_url: str = Field(default='http://localhost:8000')
|
||||
local_runtime_url: str = Field(default='http://localhost')
|
||||
keep_runtime_alive: bool = Field(default=False)
|
||||
rm_all_containers: bool = Field(default=False)
|
||||
|
||||
@@ -57,7 +57,6 @@ class AsyncEventStreamWrapper:
|
||||
class EventStream:
|
||||
sid: str
|
||||
file_store: FileStore
|
||||
secrets: dict[str, str]
|
||||
# For each subscriber ID, there is a map of callback functions - useful
|
||||
# when there are multiple listeners
|
||||
_subscribers: dict[str, dict[str, Callable]]
|
||||
@@ -83,7 +82,6 @@ class EventStream:
|
||||
self._subscribers = {}
|
||||
self._lock = threading.Lock()
|
||||
self._cur_id = 0
|
||||
self.secrets = {}
|
||||
|
||||
# load the stream
|
||||
self.__post_init__()
|
||||
@@ -269,24 +267,10 @@ class EventStream:
|
||||
event._timestamp = datetime.now().isoformat()
|
||||
event._source = source # type: ignore [attr-defined]
|
||||
data = event_to_dict(event)
|
||||
data = self._replace_secrets(data)
|
||||
event = event_from_dict(data)
|
||||
if event.id is not None:
|
||||
self.file_store.write(self._get_filename_for_id(event.id), json.dumps(data))
|
||||
self._queue.put(event)
|
||||
|
||||
def set_secrets(self, secrets: dict[str, str]):
|
||||
self.secrets = secrets.copy()
|
||||
|
||||
def _replace_secrets(self, data: dict) -> dict:
|
||||
for key in data:
|
||||
if isinstance(data[key], dict):
|
||||
data[key] = self._replace_secrets(data[key])
|
||||
elif isinstance(data[key], str):
|
||||
for secret in self.secrets.values():
|
||||
data[key] = data[key].replace(secret, '<secret_hidden>')
|
||||
return data
|
||||
|
||||
def _run_queue_loop(self):
|
||||
self._queue_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._queue_loop)
|
||||
@@ -335,7 +319,7 @@ class EventStream:
|
||||
self,
|
||||
event,
|
||||
query: str | None = None,
|
||||
event_types: tuple[type[Event], ...] | None = None,
|
||||
event_type: str | None = None,
|
||||
source: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
@@ -344,16 +328,16 @@ class EventStream:
|
||||
|
||||
Args:
|
||||
event: The event to check
|
||||
query: Text to search for in event content
|
||||
event_type: Filter by event type classes (e.g., (FileReadAction, ) ).
|
||||
source: Filter by event source
|
||||
start_date: Filter events after this date (ISO format)
|
||||
end_date: Filter events before this date (ISO format)
|
||||
query (str, optional): Text to search for in event content
|
||||
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
|
||||
source (str, optional): Filter by event source
|
||||
start_date (str, optional): Filter events after this date (ISO format)
|
||||
end_date (str, optional): Filter events before this date (ISO format)
|
||||
|
||||
Returns:
|
||||
bool: True if the event should be filtered out, False if it matches all criteria
|
||||
"""
|
||||
if event_types and not isinstance(event, event_types):
|
||||
if event_type and not event.__class__.__name__ == event_type:
|
||||
return True
|
||||
|
||||
if source and not event.source.value == source:
|
||||
@@ -377,25 +361,23 @@ class EventStream:
|
||||
def get_matching_events(
|
||||
self,
|
||||
query: str | None = None,
|
||||
event_types: tuple[type[Event], ...] | None = None,
|
||||
event_type: str | None = None,
|
||||
source: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
start_id: int = 0,
|
||||
limit: int = 100,
|
||||
reverse: bool = False,
|
||||
) -> list[type[Event]]:
|
||||
) -> list:
|
||||
"""Get matching events from the event stream based on filters.
|
||||
|
||||
Args:
|
||||
query: Text to search for in event content
|
||||
event_types: Filter by event type classes (e.g., (FileReadAction, ) ).
|
||||
source: Filter by event source
|
||||
start_date: Filter events after this date (ISO format)
|
||||
end_date: Filter events before this date (ISO format)
|
||||
start_id: Starting ID in the event stream. Defaults to 0
|
||||
limit: Maximum number of events to return. Must be between 1 and 100. Defaults to 100
|
||||
reverse: Whether to retrieve events in reverse order. Defaults to False.
|
||||
query (str, optional): Text to search for in event content
|
||||
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
|
||||
source (str, optional): Filter by event source
|
||||
start_date (str, optional): Filter events after this date (ISO format)
|
||||
end_date (str, optional): Filter events before this date (ISO format)
|
||||
start_id (int): Starting ID in the event stream. Defaults to 0
|
||||
limit (int): Maximum number of events to return. Must be between 1 and 100. Defaults to 100
|
||||
|
||||
Returns:
|
||||
list: List of matching events (as dicts)
|
||||
@@ -408,13 +390,13 @@ class EventStream:
|
||||
|
||||
matching_events: list = []
|
||||
|
||||
for event in self.get_events(start_id=start_id, reverse=reverse):
|
||||
for event in self.get_events(start_id=start_id):
|
||||
if self._should_filter_event(
|
||||
event, query, event_types, source, start_date, end_date
|
||||
event, query, event_type, source, start_date, end_date
|
||||
):
|
||||
continue
|
||||
|
||||
matching_events.append(event)
|
||||
matching_events.append(event_to_dict(event))
|
||||
|
||||
# Stop if we have enough events
|
||||
if len(matching_events) >= limit:
|
||||
|
||||
@@ -200,6 +200,7 @@ ASSISTANT:
|
||||
Running the updated file:
|
||||
<function=execute_bash>
|
||||
<parameter=command>
|
||||
<parameter=command>
|
||||
python3 app.py > server.log 2>&1 &
|
||||
</parameter>
|
||||
</function>
|
||||
|
||||
@@ -185,4 +185,4 @@ You can customize how the AI agent approaches issue resolution by adding a `.ope
|
||||
## Troubleshooting
|
||||
|
||||
If you have any issues, please open an issue on this github repo, we're happy to help!
|
||||
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the OpenHands Slack workspace (see [the README](/README.md) for an invite link).
|
||||
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the [OpenHands Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) and ask there.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import atexit
|
||||
from functools import lru_cache
|
||||
from typing import Callable
|
||||
from uuid import UUID
|
||||
|
||||
import docker
|
||||
import requests
|
||||
@@ -26,7 +26,6 @@ from openhands.runtime.utils.command import get_action_execution_server_startup_
|
||||
from openhands.runtime.utils.log_streamer import LogStreamer
|
||||
from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.shutdown_listener import add_shutdown_listener
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
@@ -37,6 +36,13 @@ APP_PORT_RANGE_1 = (50000, 54999)
|
||||
APP_PORT_RANGE_2 = (55000, 59999)
|
||||
|
||||
|
||||
def stop_all_runtime_containers():
|
||||
stop_all_containers(CONTAINER_NAME_PREFIX)
|
||||
|
||||
|
||||
_atexit_registered = False
|
||||
|
||||
|
||||
class DockerRuntime(ActionExecutionClient):
|
||||
"""This runtime will subscribe the event stream.
|
||||
When receive an event, it will send the event to runtime-client which run inside the docker environment.
|
||||
@@ -49,8 +55,6 @@ class DockerRuntime(ActionExecutionClient):
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
|
||||
_shutdown_listener_id: UUID | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
@@ -62,10 +66,10 @@ class DockerRuntime(ActionExecutionClient):
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
):
|
||||
if not DockerRuntime._shutdown_listener_id:
|
||||
DockerRuntime._shutdown_listener_id = add_shutdown_listener(
|
||||
lambda: stop_all_containers(CONTAINER_NAME_PREFIX)
|
||||
)
|
||||
global _atexit_registered
|
||||
if not _atexit_registered:
|
||||
_atexit_registered = True
|
||||
atexit.register(stop_all_runtime_containers)
|
||||
|
||||
self.config = config
|
||||
self._runtime_initialized: bool = False
|
||||
|
||||
@@ -68,10 +68,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
'debug',
|
||||
'Setting workspace_base is not supported in the remote runtime.',
|
||||
)
|
||||
if self.config.sandbox.remote_runtime_api_url is None:
|
||||
raise ValueError(
|
||||
'remote_runtime_api_url is required in the remote runtime.'
|
||||
)
|
||||
|
||||
self.runtime_builder = RemoteRuntimeBuilder(
|
||||
self.config.sandbox.remote_runtime_api_url,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def get_github_token(request: Request) -> str | None:
|
||||
return getattr(request.state, 'github_token', None)
|
||||
|
||||
|
||||
def get_user_id(request: Request) -> str | None:
|
||||
return getattr(request.state, 'github_user_id', None)
|
||||
|
||||
@@ -6,13 +6,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.middleware import (
|
||||
AttachConversationMiddleware,
|
||||
CacheControlMiddleware,
|
||||
GitHubTokenMiddleware,
|
||||
InMemoryRateLimiter,
|
||||
LocalhostCORSMiddleware,
|
||||
RateLimitMiddleware,
|
||||
)
|
||||
from openhands.server.types import AppMode, OpenhandsConfigInterface
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
@@ -50,8 +48,6 @@ class OpenhandsConfig(OpenhandsConfigInterface):
|
||||
return config
|
||||
|
||||
def attach_middleware(self, api: FastAPI) -> None:
|
||||
SettingsStoreImpl = get_impl(SettingsStore, self.settings_store_class) # type: ignore
|
||||
|
||||
api.add_middleware(
|
||||
LocalhostCORSMiddleware,
|
||||
allow_credentials=True,
|
||||
@@ -65,7 +61,6 @@ class OpenhandsConfig(OpenhandsConfigInterface):
|
||||
rate_limiter=InMemoryRateLimiter(requests=10, seconds=1),
|
||||
)
|
||||
api.middleware('http')(AttachConversationMiddleware(api))
|
||||
api.middleware('http')(GitHubTokenMiddleware(api, SettingsStoreImpl)) # type: ignore
|
||||
|
||||
|
||||
def load_openhands_config():
|
||||
|
||||
@@ -12,9 +12,7 @@ from starlette.requests import Request as StarletteRequest
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from openhands.server import shared
|
||||
from openhands.server.auth import get_user_id
|
||||
from openhands.server.types import SessionMiddlewareInterface
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
class LocalhostCORSMiddleware(CORSMiddleware):
|
||||
@@ -182,22 +180,3 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
|
||||
await self._detach_session(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class GitHubTokenMiddleware(SessionMiddlewareInterface):
|
||||
def __init__(self, app, settings_store: SettingsStore):
|
||||
self.app = app
|
||||
self.settings_store_impl = settings_store
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable):
|
||||
settings_store = await self.settings_store_impl.get_instance(
|
||||
shared.config, get_user_id(request)
|
||||
)
|
||||
settings = await settings_store.load()
|
||||
|
||||
if settings and settings.github_token:
|
||||
request.state.github_token = settings.github_token
|
||||
else:
|
||||
request.state.github_token = None
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
@@ -2,26 +2,11 @@ from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.serialization.event import event_from_dict, event_to_dict
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
|
||||
|
||||
def str_to_event_type(event: str | None) -> Event | None:
|
||||
if not event:
|
||||
return None
|
||||
|
||||
for event_type in ['observation', 'action']:
|
||||
try:
|
||||
return event_from_dict({event_type: event})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@app.get('/config')
|
||||
async def get_remote_runtime_config(request: Request):
|
||||
"""Retrieve the runtime configuration.
|
||||
@@ -119,14 +104,14 @@ async def search_events(
|
||||
):
|
||||
"""Search through the event stream with filtering and pagination.
|
||||
Args:
|
||||
request: The incoming request object
|
||||
query: Text to search for in event content
|
||||
start_id: Starting ID in the event stream. Defaults to 0
|
||||
limit: Maximum number of events to return. Must be between 1 and 100. Defaults to 20
|
||||
event_type: Filter by event type (e.g., "FileReadAction")
|
||||
source: Filter by event source
|
||||
start_date: Filter events after this date (ISO format)
|
||||
end_date: Filter events before this date (ISO format)
|
||||
request (Request): The incoming request object
|
||||
query (str, optional): Text to search for in event content
|
||||
start_id (int): Starting ID in the event stream. Defaults to 0
|
||||
limit (int): Maximum number of events to return. Must be between 1 and 100. Defaults to 20
|
||||
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
|
||||
source (str, optional): Filter by event source
|
||||
start_date (str, optional): Filter events after this date (ISO format)
|
||||
end_date (str, optional): Filter events before this date (ISO format)
|
||||
Returns:
|
||||
dict: Dictionary containing:
|
||||
- events: List of matching events
|
||||
@@ -141,11 +126,9 @@ async def search_events(
|
||||
)
|
||||
# Get matching events from the stream
|
||||
event_stream = request.state.conversation.event_stream
|
||||
|
||||
cast_event_type = str_to_event_type(event_type)
|
||||
matching_events = event_stream.get_matching_events(
|
||||
query=query,
|
||||
event_types=(cast_event_type),
|
||||
event_type=event_type,
|
||||
source=source,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
@@ -156,8 +139,6 @@ async def search_events(
|
||||
has_more = len(matching_events) > limit
|
||||
if has_more:
|
||||
matching_events = matching_events[:limit] # Remove the extra event
|
||||
|
||||
matching_events = [event_to_dict(event) for event in matching_events]
|
||||
return {
|
||||
'events': matching_events,
|
||||
'has_more': has_more,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import httpx
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.server.auth import get_github_token
|
||||
from openhands.server.shared import openhands_config
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
@@ -11,13 +10,12 @@ app = APIRouter(prefix='/api/github')
|
||||
|
||||
|
||||
def require_github_token(request: Request):
|
||||
github_token = get_github_token(request)
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not github_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Missing GitHub token',
|
||||
status_code=400,
|
||||
detail='Missing X-GitHub-Token header',
|
||||
)
|
||||
|
||||
return github_token
|
||||
|
||||
|
||||
@@ -92,19 +90,23 @@ async def get_github_installation_ids(
|
||||
):
|
||||
headers = generate_github_headers(github_token)
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get('https://api.github.com/user/installations', headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
ids = [installation['id'] for installation in data['installations']]
|
||||
return JSONResponse(content=ids)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
response = await call_sync_from_async(
|
||||
requests.get, 'https://api.github.com/user/installations', headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code if hasattr(e, 'response') else 500,
|
||||
status_code=response.status_code if response else 500,
|
||||
detail=f'Error fetching installations: {str(e)}',
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
ids = [installation['id'] for installation in data['installations']]
|
||||
json_response = JSONResponse(content=ids)
|
||||
response.close()
|
||||
|
||||
return json_response
|
||||
|
||||
|
||||
@app.get('/search/repositories')
|
||||
async def search_github_repositories(
|
||||
|
||||
@@ -10,7 +10,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.stream import EventStreamSubscriber
|
||||
from openhands.runtime import get_runtime_cls
|
||||
from openhands.server.auth import get_github_token, get_user_id
|
||||
from openhands.server.auth import get_user_id
|
||||
from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.server.shared import config, conversation_manager
|
||||
@@ -32,6 +32,7 @@ UPDATED_AT_CALLBACK_ID = 'updated_at_callback_id'
|
||||
|
||||
|
||||
class InitSessionRequest(BaseModel):
|
||||
github_token: str | None = None
|
||||
selected_repository: str | None = None
|
||||
initial_user_msg: str | None = None
|
||||
image_urls: list[str] | None = None
|
||||
@@ -126,7 +127,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
"""
|
||||
logger.info('Initializing new conversation')
|
||||
user_id = get_user_id(request)
|
||||
github_token = get_github_token(request)
|
||||
github_token = getattr(request.state, 'github_token', '') or data.github_token
|
||||
selected_repository = data.selected_repository
|
||||
initial_user_msg = data.initial_user_msg
|
||||
image_urls = data.image_urls or []
|
||||
|
||||
@@ -3,12 +3,10 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.auth import get_user_id
|
||||
from openhands.server.services.github_service import GitHubService
|
||||
from openhands.server.settings import Settings, SettingsWithTokenMeta
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.shared import config, openhands_config
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
@@ -21,7 +19,7 @@ ConversationStoreImpl = get_impl(
|
||||
|
||||
|
||||
@app.get('/settings')
|
||||
async def load_settings(request: Request) -> SettingsWithTokenMeta | None:
|
||||
async def load_settings(request: Request) -> Settings | None:
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
@@ -32,16 +30,7 @@ async def load_settings(request: Request) -> SettingsWithTokenMeta | None:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Settings not found'},
|
||||
)
|
||||
|
||||
github_token = request.state.github_token
|
||||
settings_with_token_data = SettingsWithTokenMeta(
|
||||
**settings.model_dump(),
|
||||
github_token_is_set=bool(github_token),
|
||||
)
|
||||
settings_with_token_data.llm_api_key = settings.llm_api_key
|
||||
|
||||
del settings_with_token_data.github_token
|
||||
return settings_with_token_data
|
||||
return settings
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid token: {e}')
|
||||
return JSONResponse(
|
||||
@@ -53,24 +42,8 @@ async def load_settings(request: Request) -> SettingsWithTokenMeta | None:
|
||||
@app.post('/settings')
|
||||
async def store_settings(
|
||||
request: Request,
|
||||
settings: SettingsWithTokenMeta,
|
||||
settings: Settings,
|
||||
) -> JSONResponse:
|
||||
# Check if token is valid
|
||||
if settings.github_token:
|
||||
try:
|
||||
# We check if the token is valid by getting the user
|
||||
# If the token is invalid, this will raise an exception
|
||||
github = GitHubService(settings.github_token)
|
||||
await call_sync_from_async(github.get_user)
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid GitHub token: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={
|
||||
'error': 'Invalid GitHub token. Please make sure it is valid.'
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
@@ -82,46 +55,21 @@ async def store_settings(
|
||||
if settings.llm_api_key is None:
|
||||
settings.llm_api_key = existing_settings.llm_api_key
|
||||
|
||||
if settings.github_token is None:
|
||||
settings.github_token = existing_settings.github_token
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Settings stored'},
|
||||
)
|
||||
|
||||
if settings.unset_github_token:
|
||||
settings.github_token = None
|
||||
|
||||
# Update sandbox config with new settings
|
||||
if settings.remote_runtime_resource_factor is not None:
|
||||
config.sandbox.remote_runtime_resource_factor = (
|
||||
settings.remote_runtime_resource_factor
|
||||
)
|
||||
|
||||
settings = convert_to_settings(settings)
|
||||
|
||||
await settings_store.store(settings)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.warning(f'Something went wrong storing settings: {e}')
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong storing settings'},
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Settings stored'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid token: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Invalid token'},
|
||||
)
|
||||
|
||||
|
||||
def convert_to_settings(settings_with_token_data: SettingsWithTokenMeta) -> Settings:
|
||||
settings_data = settings_with_token_data.model_dump()
|
||||
|
||||
# Filter out additional fields from `SettingsWithTokenData`
|
||||
filtered_settings_data = {
|
||||
key: value
|
||||
for key, value in settings_data.items()
|
||||
if key in Settings.model_fields # Ensures only `Settings` fields are included
|
||||
}
|
||||
|
||||
# Convert the `llm_api_key` to a `SecretStr` instance
|
||||
filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key
|
||||
|
||||
return Settings(**filtered_settings_data)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import requests
|
||||
|
||||
|
||||
class GitHubService:
|
||||
def __init__(self, token: str):
|
||||
self.token = token
|
||||
self.headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
|
||||
def get_user(self):
|
||||
response = requests.get('https://api.github.com/user', headers=self.headers)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
@@ -110,12 +110,6 @@ class AgentSession:
|
||||
agent_to_llm_config=agent_to_llm_config,
|
||||
agent_configs=agent_configs,
|
||||
)
|
||||
if github_token:
|
||||
self.event_stream.set_secrets(
|
||||
{
|
||||
'github_token': github_token,
|
||||
}
|
||||
)
|
||||
if initial_message:
|
||||
self.event_stream.add_event(initial_message, EventSource.USER)
|
||||
self.event_stream.add_event(
|
||||
|
||||
@@ -6,7 +6,6 @@ import socketio
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.config.condenser_config import AmortizedForgettingCondenserConfig
|
||||
from openhands.core.const.guide_url import TROUBLESHOOTING_URL
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema import AgentState
|
||||
@@ -106,14 +105,6 @@ class Session:
|
||||
|
||||
llm = LLM(config=self.config.get_llm_config_from_agent(agent_cls))
|
||||
agent_config = self.config.get_agent_config(agent_cls)
|
||||
|
||||
if settings.enable_default_condenser:
|
||||
default_condenser_config = AmortizedForgettingCondenserConfig(
|
||||
keep_first=3, max_size=20
|
||||
)
|
||||
logger.info(f'Enabling default condenser: {default_condenser_config}')
|
||||
agent_config.condenser = default_condenser_config
|
||||
|
||||
agent = Agent.get_cls(agent_cls)(llm, agent_config)
|
||||
|
||||
github_token = None
|
||||
|
||||
@@ -21,8 +21,6 @@ class Settings(BaseModel):
|
||||
llm_api_key: SecretStr | None = None
|
||||
llm_base_url: str | None = None
|
||||
remote_runtime_resource_factor: int | None = None
|
||||
github_token: str | None = None
|
||||
enable_default_condenser: bool = False
|
||||
|
||||
@field_serializer('llm_api_key')
|
||||
def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo):
|
||||
@@ -54,15 +52,5 @@ class Settings(BaseModel):
|
||||
llm_api_key=llm_config.api_key,
|
||||
llm_base_url=llm_config.base_url,
|
||||
remote_runtime_resource_factor=app_config.sandbox.remote_runtime_resource_factor,
|
||||
github_token=None,
|
||||
)
|
||||
return settings
|
||||
|
||||
|
||||
class SettingsWithTokenMeta(Settings):
|
||||
"""
|
||||
Settings with additional token data for the frontend
|
||||
"""
|
||||
|
||||
github_token_is_set: bool | None = None
|
||||
unset_github_token: bool | None = None
|
||||
|
||||
@@ -60,19 +60,5 @@ class GoogleCloudFileStore(FileStore):
|
||||
return list(blobs)
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
# Sanitize path
|
||||
if not path or path == '/':
|
||||
path = ''
|
||||
if path.endswith('/'):
|
||||
path = path[:-1]
|
||||
|
||||
# Try to delete any child resources (Assume the path is a directory)
|
||||
for blob in self.bucket.list_blobs(prefix=f'{path}/'):
|
||||
blob.delete()
|
||||
|
||||
# Next try to delete item as a file
|
||||
try:
|
||||
blob = self.bucket.blob(path)
|
||||
blob.delete()
|
||||
except NotFound:
|
||||
pass
|
||||
blob = self.bucket.blob(path)
|
||||
blob.delete()
|
||||
|
||||
@@ -45,8 +45,7 @@ class S3FileStore(FileStore):
|
||||
def read(self, path: str) -> str:
|
||||
try:
|
||||
response = self.client.get_object(Bucket=self.bucket, Key=path)
|
||||
with response['Body'] as stream:
|
||||
return stream.read().decode('utf-8')
|
||||
return response['Body'].read().decode('utf-8')
|
||||
except botocore.exceptions.ClientError as e:
|
||||
# Catch all S3-related errors
|
||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
||||
@@ -67,53 +66,36 @@ class S3FileStore(FileStore):
|
||||
)
|
||||
|
||||
def list(self, path: str) -> list[str]:
|
||||
if not path or path == '/':
|
||||
path = ''
|
||||
elif not path.endswith('/'):
|
||||
if path and path != '/' and not path.endswith('/'):
|
||||
path += '/'
|
||||
# The delimiter logic screens out directories, so we can't use it. :(
|
||||
# For example, given a structure:
|
||||
# foo/bar/zap.txt
|
||||
# foo/bar/bang.txt
|
||||
# ping.txt
|
||||
# prefix=None, delimiter="/" yields ["ping.txt"] # :(
|
||||
# prefix="foo", delimiter="/" yields [] # :(
|
||||
results = set()
|
||||
prefix_len = len(path)
|
||||
response = self.client.list_objects_v2(Bucket=self.bucket, Prefix=path)
|
||||
contents = response.get('Contents')
|
||||
if not contents:
|
||||
return []
|
||||
paths = [obj['Key'] for obj in response['Contents']]
|
||||
for sub_path in paths:
|
||||
if sub_path == path:
|
||||
continue
|
||||
try:
|
||||
index = sub_path.index('/', prefix_len + 1)
|
||||
if index != prefix_len:
|
||||
results.add(sub_path[: index + 1])
|
||||
except ValueError:
|
||||
results.add(sub_path)
|
||||
return list(results)
|
||||
try:
|
||||
response = self.client.list_objects_v2(Bucket=self.bucket, Prefix=path)
|
||||
# Check if 'Contents' exists in the response
|
||||
if 'Contents' in response:
|
||||
objects = [obj['Key'] for obj in response['Contents']]
|
||||
return objects
|
||||
else:
|
||||
return list()
|
||||
except botocore.exceptions.ClientError as e:
|
||||
# Catch all S3-related errors
|
||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
||||
raise FileNotFoundError(
|
||||
f"Error: The bucket '{self.bucket}' does not exist."
|
||||
)
|
||||
elif e.response['Error']['Code'] == 'AccessDenied':
|
||||
raise FileNotFoundError(
|
||||
f"Error: Access denied to bucket '{self.bucket}'."
|
||||
)
|
||||
else:
|
||||
raise FileNotFoundError(f"Error: {e.response['Error']['Message']}")
|
||||
except Exception as e:
|
||||
raise FileNotFoundError(
|
||||
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}"
|
||||
)
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
try:
|
||||
# Sanitize path
|
||||
if not path or path == '/':
|
||||
path = ''
|
||||
if path.endswith('/'):
|
||||
path = path[:-1]
|
||||
|
||||
# Try to delete any child resources (Assume the path is a directory)
|
||||
response = self.client.list_objects_v2(
|
||||
Bucket=self.bucket, Prefix=f'{path}/'
|
||||
)
|
||||
for content in response.get('Contents') or []:
|
||||
self.client.delete_object(Bucket=self.bucket, Key=content['Key'])
|
||||
|
||||
# Next try to delete item as a file
|
||||
self.client.delete_object(Bucket=self.bucket, Key=path)
|
||||
|
||||
except botocore.exceptions.ClientError as e:
|
||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
||||
raise FileNotFoundError(
|
||||
|
||||
@@ -2,8 +2,6 @@ from dataclasses import dataclass, field
|
||||
|
||||
import requests
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpSession:
|
||||
@@ -17,11 +15,10 @@ class HttpSession:
|
||||
|
||||
def __getattr__(self, name):
|
||||
if self.session is None:
|
||||
logger.error(
|
||||
'Session is being used after close!', stack_info=True, exc_info=True
|
||||
)
|
||||
raise ValueError('session_was_closed')
|
||||
return object.__getattribute__(self.session, name)
|
||||
|
||||
def close(self):
|
||||
if self.session is not None:
|
||||
self.session.close()
|
||||
self.session = None
|
||||
|
||||
@@ -135,6 +135,27 @@ class PromptManager:
|
||||
def get_system_message(self) -> str:
|
||||
return self.system_template.render().strip()
|
||||
|
||||
def get_additional_info(self) -> str:
|
||||
"""Gets information about the repository and runtime.
|
||||
|
||||
This is used to inject information about the repository and runtime into the initial user message.
|
||||
"""
|
||||
repo_instructions = ''
|
||||
assert (
|
||||
len(self.repo_microagents) <= 1
|
||||
), f'Expecting at most one repo microagent, but found {len(self.repo_microagents)}: {self.repo_microagents.keys()}'
|
||||
for microagent in self.repo_microagents.values():
|
||||
# We assume these are the repo instructions
|
||||
if repo_instructions:
|
||||
repo_instructions += '\n\n'
|
||||
repo_instructions += microagent.content
|
||||
|
||||
return ADDITIONAL_INFO_TEMPLATE.render(
|
||||
repository_instructions=repo_instructions,
|
||||
repository_info=self.repository_info,
|
||||
runtime_info=self.runtime_info,
|
||||
).strip()
|
||||
|
||||
def set_runtime_info(self, runtime: Runtime):
|
||||
self.runtime_info.available_hosts = runtime.web_hosts
|
||||
|
||||
@@ -184,43 +205,6 @@ class PromptManager:
|
||||
micro_text += '\n</extra_info>'
|
||||
message.content.append(TextContent(text=micro_text))
|
||||
|
||||
def add_examples_to_initial_message(self, message: Message) -> None:
|
||||
"""Add example_message to the first user message."""
|
||||
example_message = self.get_example_user_message() or None
|
||||
|
||||
# Insert it at the start of the TextContent list
|
||||
if example_message:
|
||||
message.content.insert(0, TextContent(text=example_message))
|
||||
|
||||
def add_info_to_initial_message(
|
||||
self,
|
||||
message: Message,
|
||||
) -> None:
|
||||
"""Adds information about the repository and runtime to the initial user message.
|
||||
|
||||
Args:
|
||||
message: The initial user message to add information to.
|
||||
"""
|
||||
repo_instructions = ''
|
||||
assert (
|
||||
len(self.repo_microagents) <= 1
|
||||
), f'Expecting at most one repo microagent, but found {len(self.repo_microagents)}: {self.repo_microagents.keys()}'
|
||||
for microagent in self.repo_microagents.values():
|
||||
# We assume these are the repo instructions
|
||||
if repo_instructions:
|
||||
repo_instructions += '\n\n'
|
||||
repo_instructions += microagent.content
|
||||
|
||||
additional_info = ADDITIONAL_INFO_TEMPLATE.render(
|
||||
repository_instructions=repo_instructions,
|
||||
repository_info=self.repository_info,
|
||||
runtime_info=self.runtime_info,
|
||||
).strip()
|
||||
|
||||
# Insert the new content at the start of the TextContent list
|
||||
if additional_info:
|
||||
message.content.insert(0, TextContent(text=additional_info))
|
||||
|
||||
def add_turns_left_reminder(self, messages: list[Message], state: State) -> None:
|
||||
latest_user_message = next(
|
||||
islice(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""
|
||||
This module monitors the app for shutdown signals. This exists because the atexit module
|
||||
does not play nocely with stareltte / uvicorn shutdown signals.
|
||||
This module monitors the app for shutdown signals
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -8,15 +7,12 @@ import signal
|
||||
import threading
|
||||
import time
|
||||
from types import FrameType
|
||||
from typing import Callable
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from uvicorn.server import HANDLED_SIGNALS
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
_should_exit = None
|
||||
_shutdown_listeners: dict[UUID, Callable] = {}
|
||||
|
||||
|
||||
def _register_signal_handler(sig: signal.Signals):
|
||||
@@ -25,16 +21,9 @@ def _register_signal_handler(sig: signal.Signals):
|
||||
def handler(sig_: int, frame: FrameType | None):
|
||||
logger.debug(f'shutdown_signal:{sig_}')
|
||||
global _should_exit
|
||||
if not _should_exit:
|
||||
_should_exit = True
|
||||
listeners = list(_shutdown_listeners.values())
|
||||
for callable in listeners:
|
||||
try:
|
||||
callable()
|
||||
except Exception:
|
||||
logger.exception('Error calling shutdown listener')
|
||||
if original_handler:
|
||||
original_handler(sig_, frame) # type: ignore[unreachable]
|
||||
_should_exit = True
|
||||
if original_handler:
|
||||
original_handler(sig_, frame) # type: ignore[unreachable]
|
||||
|
||||
original_handler = signal.signal(sig, handler)
|
||||
|
||||
@@ -82,13 +71,3 @@ async def async_sleep_if_should_continue(timeout: float):
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout and should_continue():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
def add_shutdown_listener(callable: Callable) -> UUID:
|
||||
id_ = uuid4()
|
||||
_shutdown_listeners[id_] = callable
|
||||
return id_
|
||||
|
||||
|
||||
def remove_shutdown_listener(id_: UUID) -> bool:
|
||||
return _shutdown_listeners.pop(id_, None) is not None
|
||||
|
||||
28
package-lock.json
generated
Normal file
28
package-lock.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "OpenHands",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7"
|
||||
}
|
||||
}
|
||||
193
poetry.lock
generated
193
poetry.lock
generated
@@ -170,13 +170,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.45.2"
|
||||
version = "0.43.1"
|
||||
description = "The official Python library for the anthropic API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "anthropic-0.45.2-py3-none-any.whl", hash = "sha256:ecd746f7274451dfcb7e1180571ead624c7e1195d1d46cb7c70143d2aedb4d35"},
|
||||
{file = "anthropic-0.45.2.tar.gz", hash = "sha256:32a18b9ecd12c91b2be4cae6ca2ab46a06937b5aa01b21308d97a6d29794fb5e"},
|
||||
{file = "anthropic-0.43.1-py3-none-any.whl", hash = "sha256:20759c25cd0f4072eb966b0180a41c061c156473bbb674da6a3f1e92e1ad78f8"},
|
||||
{file = "anthropic-0.43.1.tar.gz", hash = "sha256:c7f13e4b7b515ac4a3111142310b214527c0fc561485e5bc9b582e49fe3adba2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -552,17 +552,17 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.36.8"
|
||||
version = "1.36.2"
|
||||
description = "The AWS SDK for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "boto3-1.36.8-py3-none-any.whl", hash = "sha256:7f61c9d0ea64f484a17c1e3115fdf90fd7b17ab6771e07cb4549f42b9fd28fb9"},
|
||||
{file = "boto3-1.36.8.tar.gz", hash = "sha256:ac47215d320b0c2534340db58d6d5284cb1860b7bff172b4dd6eee2dee1d5779"},
|
||||
{file = "boto3-1.36.2-py3-none-any.whl", hash = "sha256:76cfc9a705be46e8d22607efacc8d688c064f923d785a01c00b28e9a96425d1a"},
|
||||
{file = "boto3-1.36.2.tar.gz", hash = "sha256:fde1c29996b77274a60b7bc9f741525afa6267bb1716eb644a764fb7c124a0d2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.36.8,<1.37.0"
|
||||
botocore = ">=1.36.2,<1.37.0"
|
||||
jmespath = ">=0.7.1,<2.0.0"
|
||||
s3transfer = ">=0.11.0,<0.12.0"
|
||||
|
||||
@@ -571,13 +571,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.36.8"
|
||||
version = "1.36.2"
|
||||
description = "Low-level, data-driven core of boto 3."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "botocore-1.36.8-py3-none-any.whl", hash = "sha256:59d3fdfbae6d916b046e973bebcbeb70a102f9e570ca86d5ba512f1854b78fc2"},
|
||||
{file = "botocore-1.36.8.tar.gz", hash = "sha256:81c88e5566cf018e1411a68304dc1fb9e4156ca2b50a3a0f0befc274299e67fa"},
|
||||
{file = "botocore-1.36.2-py3-none-any.whl", hash = "sha256:bc3b7e3b573a48af2bd7116b80fe24f9a335b0b67314dcb2697a327d009abf29"},
|
||||
{file = "botocore-1.36.2.tar.gz", hash = "sha256:a1fe6610983f0214b0c7655fe6990b6a731746baf305b182976fc7b568fc3cb0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1108,6 +1108,20 @@ humanfriendly = ">=9.1"
|
||||
[package.extras]
|
||||
cron = ["capturer (>=2.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "colormath"
|
||||
version = "3.0.0"
|
||||
description = "Color math and conversion library."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "colormath-3.0.0.tar.gz", hash = "sha256:3d4605af344527da0e4f9f504fad7ddbebda35322c566a6c72e28edb1ff31217"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
networkx = ">=2.0"
|
||||
numpy = "*"
|
||||
|
||||
[[package]]
|
||||
name = "comm"
|
||||
version = "0.2.2"
|
||||
@@ -1613,13 +1627,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "e2b"
|
||||
version = "1.0.6"
|
||||
version = "1.0.5"
|
||||
description = "E2B SDK that give agents cloud environments"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.8"
|
||||
files = [
|
||||
{file = "e2b-1.0.6-py3-none-any.whl", hash = "sha256:4ae6e00d46e6b0b9ab05388c408f9155488ee9f022c5a6fd47939f492ccf3b58"},
|
||||
{file = "e2b-1.0.6.tar.gz", hash = "sha256:e35d47f5581565060a5c18e4cb839cf61de310d275fa0a6589d8fc8bf65957a7"},
|
||||
{file = "e2b-1.0.5-py3-none-any.whl", hash = "sha256:a71bdec46f33d3e38e87d475d7fd2939bd7b6b753b819c9639ca211cd375b79e"},
|
||||
{file = "e2b-1.0.5.tar.gz", hash = "sha256:43c82705af7b7d4415c2510ff77dab4dc075351e0b769d6adf8e0d7bb4868d13"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1732,23 +1746,23 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.115.7"
|
||||
version = "0.115.6"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "fastapi-0.115.7-py3-none-any.whl", hash = "sha256:eb6a8c8bf7f26009e8147111ff15b5177a0e19bb4a45bc3486ab14804539d21e"},
|
||||
{file = "fastapi-0.115.7.tar.gz", hash = "sha256:0f106da6c01d88a6786b3248fb4d7a940d071f6f488488898ad5d354b25ed015"},
|
||||
{file = "fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305"},
|
||||
{file = "fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
|
||||
starlette = ">=0.40.0,<0.46.0"
|
||||
starlette = ">=0.40.0,<0.42.0"
|
||||
typing-extensions = ">=4.8.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
|
||||
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
|
||||
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
|
||||
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "fastcore"
|
||||
@@ -2229,22 +2243,19 @@ test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit",
|
||||
|
||||
[[package]]
|
||||
name = "google-ai-generativelanguage"
|
||||
version = "0.6.15"
|
||||
version = "0.6.10"
|
||||
description = "Google Ai Generativelanguage API client library"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c"},
|
||||
{file = "google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3"},
|
||||
{file = "google_ai_generativelanguage-0.6.10-py3-none-any.whl", hash = "sha256:854a2bf833d18be05ad5ef13c755567b66a4f4a870f099b62c61fe11bddabcf4"},
|
||||
{file = "google_ai_generativelanguage-0.6.10.tar.gz", hash = "sha256:6fa642c964d8728006fe7e8771026fc0b599ae0ebeaf83caf550941e8e693455"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
|
||||
]
|
||||
proto-plus = ">=1.22.3,<2.0.0dev"
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
|
||||
[[package]]
|
||||
@@ -2278,13 +2289,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.160.0"
|
||||
version = "2.159.0"
|
||||
description = "Google API Client Library for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "google_api_python_client-2.160.0-py2.py3-none-any.whl", hash = "sha256:63d61fb3e4cf3fb31a70a87f45567c22f6dfe87bbfa27252317e3e2c42900db4"},
|
||||
{file = "google_api_python_client-2.160.0.tar.gz", hash = "sha256:a8ccafaecfa42d15d5b5c3134ced8de08380019717fc9fb1ed510ca58eca3b7e"},
|
||||
{file = "google_api_python_client-2.159.0-py2.py3-none-any.whl", hash = "sha256:baef0bb631a60a0bd7c0bf12a5499e3a40cd4388484de7ee55c1950bf820a0cf"},
|
||||
{file = "google_api_python_client-2.159.0.tar.gz", hash = "sha256:55197f430f25c907394b44fa078545ffef89d33fd4dca501b7db9f0d8e224bd6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2353,13 +2364,13 @@ tool = ["click (>=6.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform"
|
||||
version = "1.79.0"
|
||||
version = "1.77.0"
|
||||
description = "Vertex AI API client library"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "google_cloud_aiplatform-1.79.0-py2.py3-none-any.whl", hash = "sha256:e52d518c386ce2b4ce57f1b73b46c57531d9a6ccd70c21a37b349f428bfc1c3f"},
|
||||
{file = "google_cloud_aiplatform-1.79.0.tar.gz", hash = "sha256:362bfd16716dcfb6c131736f25246790002b29c99a246fcf4c08a7c71bd2301f"},
|
||||
{file = "google_cloud_aiplatform-1.77.0-py2.py3-none-any.whl", hash = "sha256:e9dd1bcb1b9a85eddd452916cd6ad1d9ce2d487772a9e45b1814aa0ac5633689"},
|
||||
{file = "google_cloud_aiplatform-1.77.0.tar.gz", hash = "sha256:1e5b77fe6c7f276d7aae65bcf08a273122a71f6c4af1f43cf45821f603a74080"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2381,8 +2392,8 @@ autologging = ["mlflow (>=1.27.0,<=2.16.0)"]
|
||||
cloud-profiler = ["tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (>=2.4.0,<3.0.0dev)", "werkzeug (>=2.0.0,<2.1.0dev)"]
|
||||
datasets = ["pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)"]
|
||||
endpoint = ["requests (>=2.28.1)"]
|
||||
evaluation = ["pandas (>=1.0.0)", "scikit-learn", "scikit-learn (<1.6.0)", "tqdm (>=4.23.0)"]
|
||||
full = ["docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.16.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)", "pyarrow (>=6.0.1)", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "requests (>=2.28.1)", "scikit-learn", "scikit-learn (<1.6.0)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)"]
|
||||
evaluation = ["pandas (>=1.0.0)", "tqdm (>=4.23.0)"]
|
||||
full = ["docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.16.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)", "pyarrow (>=6.0.1)", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "requests (>=2.28.1)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)"]
|
||||
langchain = ["langchain (>=0.1.16,<0.4)", "langchain-core (<0.4)", "langchain-google-vertexai (<3)", "langgraph (>=0.2.45,<0.3)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)"]
|
||||
langchain-testing = ["absl-py", "cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "langchain (>=0.1.16,<0.4)", "langchain-core (<0.4)", "langchain-google-vertexai (<3)", "langgraph (>=0.2.45,<0.3)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.6.3,<3)", "pytest-xdist", "typing-extensions"]
|
||||
lit = ["explainable-ai-sdk (>=1.0.0)", "lit-nlp (==0.4.0)", "pandas (>=1.0.0)", "tensorflow (>=2.3.0,<3.0.0dev)"]
|
||||
@@ -2533,16 +2544,16 @@ testing = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "google-generativeai"
|
||||
version = "0.8.4"
|
||||
version = "0.8.3"
|
||||
description = "Google Generative AI High level API client library and tools."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "google_generativeai-0.8.4-py3-none-any.whl", hash = "sha256:e987b33ea6decde1e69191ddcaec6ef974458864d243de7191db50c21a7c5b82"},
|
||||
{file = "google_generativeai-0.8.3-py3-none-any.whl", hash = "sha256:1108ff89d5b8e59f51e63d1a8bf84701cd84656e17ca28d73aeed745e736d9b7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-ai-generativelanguage = "0.6.15"
|
||||
google-ai-generativelanguage = "0.6.10"
|
||||
google-api-core = "*"
|
||||
google-api-python-client = "*"
|
||||
google-auth = ">=2.15.0"
|
||||
@@ -3903,13 +3914,13 @@ types-tqdm = "*"
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.59.9"
|
||||
version = "1.59.0"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
optional = false
|
||||
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
|
||||
files = [
|
||||
{file = "litellm-1.59.9-py3-none-any.whl", hash = "sha256:f2012e98d61d7aeb1d103a70215ddc713eb62973c58da0cd71942e771cc5f511"},
|
||||
{file = "litellm-1.59.9.tar.gz", hash = "sha256:51d6801529042a613bc3ef8d6f9bb2dbaf265ebcbeb44735cbb2503293fc6375"},
|
||||
{file = "litellm-1.59.0-py3-none-any.whl", hash = "sha256:b0c8bdee556d5dc2f9c703f7dc831574ea2e339d2e762dd626d014c170b8b587"},
|
||||
{file = "litellm-1.59.0.tar.gz", hash = "sha256:140eecb47952558414d00f7a259fe303fe5f0d073973a28f488fc6938cc45660"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3947,19 +3958,19 @@ pydantic = ">=1.10"
|
||||
|
||||
[[package]]
|
||||
name = "llama-index"
|
||||
version = "0.12.14"
|
||||
version = "0.12.13"
|
||||
description = "Interface between LLMs and your data"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
files = [
|
||||
{file = "llama_index-0.12.14-py3-none-any.whl", hash = "sha256:cafbac9f08f1f7293169bfd3c75545db3b761742ea829ba6940c3f2c3b1c2d26"},
|
||||
{file = "llama_index-0.12.14.tar.gz", hash = "sha256:aa74315b32e93a77e285519459d77b98be7db9ae4c5aa64aac2c54cc919c838f"},
|
||||
{file = "llama_index-0.12.13-py3-none-any.whl", hash = "sha256:0b285aa451ced6bd8da40df99068ac96badf8b5725c4edc29f2bce4da2ffd8bc"},
|
||||
{file = "llama_index-0.12.13.tar.gz", hash = "sha256:1e39a397dcc51dabe280c121fd8d5451a6a84595233a8b26caa54d9b7ecf9ffc"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
llama-index-agent-openai = ">=0.4.0,<0.5.0"
|
||||
llama-index-cli = ">=0.4.0,<0.5.0"
|
||||
llama-index-core = ">=0.12.14,<0.13.0"
|
||||
llama-index-core = ">=0.12.13,<0.13.0"
|
||||
llama-index-embeddings-openai = ">=0.3.0,<0.4.0"
|
||||
llama-index-indices-managed-llama-cloud = ">=0.4.0"
|
||||
llama-index-llms-openai = ">=0.3.0,<0.4.0"
|
||||
@@ -4004,13 +4015,13 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0"
|
||||
|
||||
[[package]]
|
||||
name = "llama-index-core"
|
||||
version = "0.12.14"
|
||||
version = "0.12.13"
|
||||
description = "Interface between LLMs and your data"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
files = [
|
||||
{file = "llama_index_core-0.12.14-py3-none-any.whl", hash = "sha256:6fdb30e3fadf98e7df75f9db5d06f6a7f8503ca545a71e048d786ff88012bd50"},
|
||||
{file = "llama_index_core-0.12.14.tar.gz", hash = "sha256:378bbf5bf4d1a8c692d3a980c1a6ed3be7a9afb676a4960429dea15f62d06cd3"},
|
||||
{file = "llama_index_core-0.12.13-py3-none-any.whl", hash = "sha256:9708bb594bbddffd6ff0767242e49d8978d1ba60a2e62e071d9d123ad2f17e6f"},
|
||||
{file = "llama_index_core-0.12.13.tar.gz", hash = "sha256:77af0161246ce1de38efc17cb6438dfff9e9558af00bcfac7dd4d0b7325efa4b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4779,12 +4790,12 @@ type = ["mypy (==1.11.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "modal"
|
||||
version = "0.72.58"
|
||||
version = "0.72.33"
|
||||
description = "Python client library for Modal"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "modal-0.72.58-py3-none-any.whl", hash = "sha256:4fd9d6048622546a19b1d29ab9b8ec82bb05458d95011298e52b047a75373e9a"},
|
||||
{file = "modal-0.72.33-py3-none-any.whl", hash = "sha256:ce8ac919c4ae81563ea8a122b1d9cd23c98335b2795b82cb16b9fe96c3d9fd4b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4795,7 +4806,7 @@ fastapi = "*"
|
||||
grpclib = "0.4.7"
|
||||
protobuf = ">=3.19,<4.24.0 || >4.24.0,<6.0"
|
||||
rich = ">=12.0.0"
|
||||
synchronicity = ">=0.9.9,<0.10.0"
|
||||
synchronicity = ">=0.9.8,<0.10.0"
|
||||
toml = "*"
|
||||
typer = ">=0.9"
|
||||
types-certifi = "*"
|
||||
@@ -5560,13 +5571,13 @@ sympy = "*"
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "1.60.2"
|
||||
version = "1.59.9"
|
||||
description = "The official Python library for the openai API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "openai-1.60.2-py3-none-any.whl", hash = "sha256:993bd11b96900b9098179c728026f016b4982ded7ee30dfcf4555eab1171fff9"},
|
||||
{file = "openai-1.60.2.tar.gz", hash = "sha256:a8f843e10f2855713007f491d96afb2694b11b5e02cb97c7d01a0be60bc5bb51"},
|
||||
{file = "openai-1.59.9-py3-none-any.whl", hash = "sha256:61a0608a1313c08ddf92fe793b6dbd1630675a1fe3866b2f96447ce30050c448"},
|
||||
{file = "openai-1.59.9.tar.gz", hash = "sha256:ec1a20b0351b4c3e65c6292db71d8233515437c6065efd4fd50edeb55df5f5d2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6345,22 +6356,22 @@ testing = ["google-api-core (>=1.31.5)"]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "4.25.6"
|
||||
version = "4.25.5"
|
||||
description = ""
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "protobuf-4.25.6-cp310-abi3-win32.whl", hash = "sha256:61df6b5786e2b49fc0055f636c1e8f0aff263808bb724b95b164685ac1bcc13a"},
|
||||
{file = "protobuf-4.25.6-cp310-abi3-win_amd64.whl", hash = "sha256:b8f837bfb77513fe0e2f263250f423217a173b6d85135be4d81e96a4653bcd3c"},
|
||||
{file = "protobuf-4.25.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:6d4381f2417606d7e01750e2729fe6fbcda3f9883aa0c32b51d23012bded6c91"},
|
||||
{file = "protobuf-4.25.6-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:5dd800da412ba7f6f26d2c08868a5023ce624e1fdb28bccca2dc957191e81fb5"},
|
||||
{file = "protobuf-4.25.6-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:4434ff8bb5576f9e0c78f47c41cdf3a152c0b44de475784cd3fd170aef16205a"},
|
||||
{file = "protobuf-4.25.6-cp38-cp38-win32.whl", hash = "sha256:8bad0f9e8f83c1fbfcc34e573352b17dfce7d0519512df8519994168dc015d7d"},
|
||||
{file = "protobuf-4.25.6-cp38-cp38-win_amd64.whl", hash = "sha256:b6905b68cde3b8243a198268bb46fbec42b3455c88b6b02fb2529d2c306d18fc"},
|
||||
{file = "protobuf-4.25.6-cp39-cp39-win32.whl", hash = "sha256:3f3b0b39db04b509859361ac9bca65a265fe9342e6b9406eda58029f5b1d10b2"},
|
||||
{file = "protobuf-4.25.6-cp39-cp39-win_amd64.whl", hash = "sha256:6ef2045f89d4ad8d95fd43cd84621487832a61d15b49500e4c1350e8a0ef96be"},
|
||||
{file = "protobuf-4.25.6-py3-none-any.whl", hash = "sha256:07972021c8e30b870cfc0863409d033af940213e0e7f64e27fe017b929d2c9f7"},
|
||||
{file = "protobuf-4.25.6.tar.gz", hash = "sha256:f8cfbae7c5afd0d0eaccbe73267339bff605a2315860bb1ba08eb66670a9a91f"},
|
||||
{file = "protobuf-4.25.5-cp310-abi3-win32.whl", hash = "sha256:5e61fd921603f58d2f5acb2806a929b4675f8874ff5f330b7d6f7e2e784bbcd8"},
|
||||
{file = "protobuf-4.25.5-cp310-abi3-win_amd64.whl", hash = "sha256:4be0571adcbe712b282a330c6e89eae24281344429ae95c6d85e79e84780f5ea"},
|
||||
{file = "protobuf-4.25.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:b2fde3d805354df675ea4c7c6338c1aecd254dfc9925e88c6d31a2bcb97eb173"},
|
||||
{file = "protobuf-4.25.5-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:919ad92d9b0310070f8356c24b855c98df2b8bd207ebc1c0c6fcc9ab1e007f3d"},
|
||||
{file = "protobuf-4.25.5-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:fe14e16c22be926d3abfcb500e60cab068baf10b542b8c858fa27e098123e331"},
|
||||
{file = "protobuf-4.25.5-cp38-cp38-win32.whl", hash = "sha256:98d8d8aa50de6a2747efd9cceba361c9034050ecce3e09136f90de37ddba66e1"},
|
||||
{file = "protobuf-4.25.5-cp38-cp38-win_amd64.whl", hash = "sha256:b0234dd5a03049e4ddd94b93400b67803c823cfc405689688f59b34e0742381a"},
|
||||
{file = "protobuf-4.25.5-cp39-cp39-win32.whl", hash = "sha256:abe32aad8561aa7cc94fc7ba4fdef646e576983edb94a73381b03c53728a626f"},
|
||||
{file = "protobuf-4.25.5-cp39-cp39-win_amd64.whl", hash = "sha256:7a183f592dc80aa7c8da7ad9e55091c4ffc9497b3054452d629bb85fa27c2a45"},
|
||||
{file = "protobuf-4.25.5-py3-none-any.whl", hash = "sha256:0aebecb809cae990f8129ada5ca273d9d670b76d9bfc9b1809f0a9c02b7dbf41"},
|
||||
{file = "protobuf-4.25.5.tar.gz", hash = "sha256:7f8249476b4a9473645db7f8ab42b02fe1488cbe5fb72fddd445e0665afd8584"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6993,13 +7004,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "0.25.3"
|
||||
version = "0.25.2"
|
||||
description = "Pytest support for asyncio"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"},
|
||||
{file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"},
|
||||
{file = "pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075"},
|
||||
{file = "pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7819,29 +7830,29 @@ pyasn1 = ">=0.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.9.3"
|
||||
version = "0.9.2"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ruff-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624"},
|
||||
{file = "ruff-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c"},
|
||||
{file = "ruff-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4"},
|
||||
{file = "ruff-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439"},
|
||||
{file = "ruff-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5"},
|
||||
{file = "ruff-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4"},
|
||||
{file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1"},
|
||||
{file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5"},
|
||||
{file = "ruff-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4"},
|
||||
{file = "ruff-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6"},
|
||||
{file = "ruff-0.9.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730"},
|
||||
{file = "ruff-0.9.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2"},
|
||||
{file = "ruff-0.9.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519"},
|
||||
{file = "ruff-0.9.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b"},
|
||||
{file = "ruff-0.9.3-py3-none-win32.whl", hash = "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c"},
|
||||
{file = "ruff-0.9.3-py3-none-win_amd64.whl", hash = "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4"},
|
||||
{file = "ruff-0.9.3-py3-none-win_arm64.whl", hash = "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b"},
|
||||
{file = "ruff-0.9.3.tar.gz", hash = "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a"},
|
||||
{file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
|
||||
{file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
|
||||
{file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
|
||||
{file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
|
||||
{file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
|
||||
{file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
|
||||
{file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8587,13 +8598,13 @@ dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "synchronicity"
|
||||
version = "0.9.10"
|
||||
version = "0.9.8"
|
||||
description = "Export blocking and async library versions from a single async implementation"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "synchronicity-0.9.10-py3-none-any.whl", hash = "sha256:5073064753961d930366520a4fb04d3b9acd98d6d6eba946374421a9e18ff042"},
|
||||
{file = "synchronicity-0.9.10.tar.gz", hash = "sha256:7460a471190ba53c1ae3e15f9e51ed28d8bcc80df867194686ed28eaad5f19f2"},
|
||||
{file = "synchronicity-0.9.8-py3-none-any.whl", hash = "sha256:ff1c1bec769ba8a6ded8298a30282fd32992e900a3270837d9256fd60e61862b"},
|
||||
{file = "synchronicity-0.9.8.tar.gz", hash = "sha256:f8246a2cd0c2658e260234be27eaaf3c461e11e2a80ab60b698a43c078359471"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -10119,4 +10130,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "e4deaa0871c5d98fe49cd5fed04ab09570f55c84d3e46d4e2a6f8332ae962000"
|
||||
content-hash = "fbca4b2ca0fe2d1d3cac46164c0c1eb9e468dc6f6bc7165e9a3d62ea9f25d801"
|
||||
|
||||
@@ -82,7 +82,7 @@ voyageai = "*"
|
||||
llama-index-embeddings-voyageai = "*"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.9.3"
|
||||
ruff = "0.9.2"
|
||||
mypy = "1.14.1"
|
||||
pre-commit = "4.1.0"
|
||||
build = "*"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user