Compare commits

...

33 Commits

Author SHA1 Message Date
openhands
1d31927230 Update tests to work with removal of github_user_id parameter 2025-04-25 03:42:13 +00:00
Chuck Butkus
9a24df14f1 Remove GItHub ID where it's not needed 2025-04-24 23:02:07 -04:00
chuckbutkus
956d278138 fix call to convo store get_instance (#8081) 2025-04-25 01:41:00 +00:00
மனோஜ்குமார் பழனிச்சாமி
fa186fc433 chore: Use recommended functions (#8056) 2025-04-25 09:39:19 +08:00
Ray Myers
4ef9c72da1 fix - Show error message for too many conversations (#8078) 2025-04-24 19:53:17 -05:00
tofarr
c5245a622d Refactor Authentication (#8040)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
2025-04-24 18:49:41 -06:00
Engel Nyst
9b1aaa53fe Reset the reset (#8079) 2025-04-25 00:59:37 +02:00
Rohit Malhotra
4deffa3907 [Fix]: Don't wipe out secret store on reset route (#8075) 2025-04-24 17:41:46 -04:00
Rohit Malhotra
a47c6f3ed1 [Fix]: Log HTTP err type in Git service classes (#8076) 2025-04-24 17:41:36 -04:00
dependabot[bot]
90ece3f8e1 chore(deps): bump the version-all group across 1 directory with 14 updates (#8062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-04-24 20:04:33 +00:00
Robert Brennan
a948b0fef3 Change log levels from info to debug for various agent controller messages (#8073)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-24 20:00:21 +00:00
Robert Brennan
52848cd3db Improve git handler with better branch management (#8065)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-24 12:34:25 -07:00
mamoodi
62f015370a Update Documentation with new release features (#8045) 2025-04-24 14:58:42 -04:00
sp.wack
7109b057b6 Make tmux optional for development (#8063)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2025-04-24 18:13:33 +00:00
Rohit Malhotra
e694fc2d58 [Feat]: Add suggested tasks for Gitlab (#8049) 2025-04-24 13:26:52 -04:00
Robert Brennan
50baf3fd18 Add OpenAPI spec to Docusaurus documentation (#7998)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-24 10:19:33 -07:00
dependabot[bot]
dc37f039bf chore(deps): bump the version-all group across 1 directory with 14 updates (#8064)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-24 18:01:39 +02:00
Engel Nyst
91fff77ffe Fix lint (#8060) 2025-04-24 14:34:22 +00:00
sp.wack
db01cd39d5 hotfix: Run both BE and FE pre-commits with husky (#8059) 2025-04-24 18:21:41 +04:00
மனோஜ்குமார் பழனிச்சாமி
00865fbb8a fix: reconnect if stream closed (#8055) 2025-04-24 16:05:28 +04:00
Rohit Malhotra
356cd9ff9f (Hotfix): Issue comments on Cloud Resolver (#8053) 2025-04-23 22:50:00 -04:00
Xingyao Wang
78d82408b7 fix: use lower range of tcp port for file viewer (#8051) 2025-04-24 01:51:44 +00:00
Panduka Muditha
3c4ebc3d8a fix: Add error handling for scenarios where tmux is unavailable (#8043)
Co-authored-by: Anuradha Weeraman <anuradha@verdentra.com>
2025-04-23 21:01:06 +02:00
Rohit Malhotra
964478c22f [Feat]: Custom secrets plumbing for BE (#7891)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 12:44:25 -04:00
Robert Brennan
00c449d447 Add loading indicator to repository dropdown on home page (#8015)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 12:27:49 -04:00
Rohit Malhotra
bfd75a1355 (Chore): Rm legacy resolver code (#8001) 2025-04-23 11:34:03 -04:00
Graham Neubig
dc91cb263b Add extensive typing to controller directory (#7731)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-04-23 15:33:17 +00:00
Robert Brennan
fa559ace86 Add API keys management UI to settings page (#7710)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 19:08:32 +04:00
mamoodi
1fd26d196a Release 0.34.0 (#8011) 2025-04-23 11:06:28 -04:00
Robert Brennan
5de62d85fd add an option for a headless backend (#8032) 2025-04-23 04:09:22 +02:00
Chase
5d749aeba7 replace erroneous rstrip() with removesuffix() (#8024) 2025-04-22 22:50:14 +00:00
Chase
693c72d670 remove sse subsection accessor of McpConfig in action_execution_client (#8021) 2025-04-23 00:40:10 +02:00
Engel Nyst
62557d44f2 Use short tool descriptions for o4-mini (#8022) 2025-04-23 00:30:21 +02:00
127 changed files with 7508 additions and 2458 deletions

View File

@@ -118,7 +118,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.33-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.34-nikolaik`
## Develop inside Docker container

View File

@@ -39,6 +39,7 @@ ifeq ($(INSTALL_DOCKER),)
@$(MAKE) -s check-docker
endif
@$(MAKE) -s check-poetry
@$(MAKE) -s check-tmux
@echo "$(GREEN)Dependencies checked successfully.$(RESET)"
check-system:
@@ -101,6 +102,18 @@ check-docker:
exit 1; \
fi
check-tmux:
@echo "$(YELLOW)Checking tmux installation...$(RESET)"
@if command -v tmux > /dev/null; then \
echo "$(BLUE)$(shell tmux -V) is already installed.$(RESET)"; \
else \
echo "$(YELLOW)╔════════════════════════════════════════════════════════════════════════════╗$(RESET)"; \
echo "$(YELLOW)║ OPTIONAL: tmux is not installed. ║$(RESET)"; \
echo "$(YELLOW)║ Some advanced terminal features may not work without tmux. ║$(RESET)"; \
echo "$(YELLOW)║ You can install it if needed, but it's not required for development. ║$(RESET)"; \
echo "$(YELLOW)╚════════════════════════════════════════════════════════════════════════════╝$(RESET)"; \
fi
check-poetry:
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
@if command -v poetry > /dev/null; then \

View File

@@ -52,17 +52,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

View File

@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.33-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.34-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

1
docs/.gitignore vendored
View File

@@ -3,6 +3,7 @@
# Production
/build
/static/swagger-ui
# Generated files
.docusaurus

View File

@@ -36,6 +36,7 @@ const config: Config = {
mermaid: true,
},
themes: ['@docusaurus/theme-mermaid'],
plugins: [],
presets: [
[
'classic',
@@ -75,6 +76,11 @@ const config: Config = {
position: 'left',
label: 'User Guides',
},
{
href: 'https://docs.all-hands.dev/swagger-ui/', // FIXME: this should be a relative path, but docusarus steals the click
label: 'API',
position: 'left',
},
{
type: 'localeDropdown',
position: 'left',

102
docs/generate-swagger-ui.js Normal file
View File

@@ -0,0 +1,102 @@
const fs = require('fs');
const path = require('path');
const swaggerUiDist = require('swagger-ui-dist');
/**
* This script manually sets up Swagger UI for the Docusaurus documentation.
*
* Why we need this approach:
* 1. Docusaurus doesn't have a built-in way to integrate Swagger UI
* 2. We need to copy the necessary files from swagger-ui-dist to our static directory
* 3. We need to create a custom index.html file that points to our OpenAPI spec
* 4. This approach allows us to customize the Swagger UI to match our documentation style
*/
// Get the absolute path to the swagger-ui-dist package
const swaggerUiDistPath = swaggerUiDist.getAbsoluteFSPath();
// Create the target directory if it doesn't exist
const targetDir = path.join(__dirname, 'static', 'swagger-ui');
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// Copy all files from swagger-ui-dist to our target directory
const files = fs.readdirSync(swaggerUiDistPath);
files.forEach(file => {
const sourcePath = path.join(swaggerUiDistPath, file);
const targetPath = path.join(targetDir, file);
// Skip directories and non-essential files
if (fs.statSync(sourcePath).isDirectory() ||
file === 'package.json' ||
file === 'README.md' ||
file.endsWith('.map')) {
return;
}
fs.copyFileSync(sourcePath, targetPath);
});
// Create a custom index.html file that points to our OpenAPI spec
const indexHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OpenHands API Documentation</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
margin: 0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "/openapi.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
// End Swagger UI call region
window.ui = ui;
};
</script>
</body>
</html>
`;
fs.writeFileSync(path.join(targetDir, 'index.html'), indexHtml);
console.log('Swagger UI files generated successfully in static/swagger-ui/');

View File

@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -42,7 +42,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
```bash
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-v $WORKSPACE_BASE:/opt/workspace_base \
@@ -82,5 +82,5 @@ docker network create openhands-network
# 分離されたネットワークで OpenHands を実行
docker run # ... \
--network openhands-network \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```

View File

@@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "escreva um script bash que imprima oi"
```

View File

@@ -58,17 +58,17 @@
A maneira mais fácil de executar o OpenHands é no Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
Você encontrará o OpenHands em execução em http://localhost:3000!

View File

@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。

View File

@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -8,18 +8,22 @@ OpenHands Cloud can be accessed at https://app.all-hands.dev/.
## Getting Started
After visiting OpenHands Cloud, you will be asked to connect with your GitHub account:
1. After reading and accepting the terms of service, click `Connect to GitHub`.
After visiting OpenHands Cloud, you will be asked to connect with your GitHub or GitLab account:
1. After reading and accepting the terms of service, click `Log in with GitHub` or `Log in with GitLab`.
2. Review the permissions requested by OpenHands and then click `Authorize OpenHands AI`.
- OpenHands will require some permissions from your GitHub account. To read more about these permissions,
you can click the `Learn more` link on the GitHub authorize page.
- OpenHands will require some permissions from your GitHub or GitLab account. To read more about these permissions:
- GitHub: You can click the `Learn more` link on the GitHub authorize page.
- GitLab: You can expand each permission request on the GitLab authorize page.
## Repository Access
### Adding Repository Access
### GitHub
#### Adding Repository Access
You can grant OpenHands specific repository access:
1. Click the `Select a Git project` dropdown, select `Add more repositories...`.
1. Click `Add GitHub repos` on the Home page.
2. Select the organization, then choose the specific repositories to grant OpenHands access to.
<details>
<summary>Permission Details for Repository Access</summary>
@@ -42,11 +46,15 @@ You can grant OpenHands specific repository access:
3. Click on `Install & Authorize`.
### Modifying Repository Access
#### Modifying Repository Access
You can modify repository access at any time by:
* Using the same `Select a Git project > Add more repositories` workflow, or
* Visiting the Settings page and selecting `Configure GitHub Repositories` under the `GitHub Settings` section.
You can modify GitHub repository access at any time by:
* Using the same `Add GitHub repos` workflow, or
* Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git Settings` section.
### GitLab
When using your GitLab account, OpenHands will automatically have access to your repositories.
## Conversation Persistence

View File

@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -6,6 +6,9 @@
- Displays the conversation between the user and OpenHands.
- OpenHands explains its actions in this panel.
### Changes
- Shows the file changes performed by OpenHands.
### Workspace
- Browse project files and directories.
- Use the `Open in VS Code` option to:
@@ -20,7 +23,7 @@
- Particularly handy when using OpenHands to perform data visualization tasks.
### App
- Shows the web server when OpenHands runs an application.
- Displays the web server when OpenHands runs an application.
- Users can interact with the running application.
### Browser

433
docs/package-lock.json generated
View File

@@ -24,6 +24,8 @@
"@docusaurus/module-type-aliases": "^3.5.1",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.5.1",
"swagger-cli": "^4.0.4",
"swagger-ui-dist": "^5.21.0",
"typescript": "~5.8.3"
},
"engines": {
@@ -273,6 +275,273 @@
"node": ">=6.0.0"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-cli": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-cli/-/swagger-cli-4.0.4.tgz",
"integrity": "sha512-hdDT3B6GLVovCsRZYDi3+wMcB1HfetTU20l2DC8zD3iFRNMC6QNAZG5fo/6PYeHWBEv7ri4MvnlKodhNB0nt7g==",
"deprecated": "This package has been abandoned. Please switch to using the actively maintained @redocly/cli",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "^10.0.1",
"chalk": "^4.1.0",
"js-yaml": "^3.14.0",
"yargs": "^15.4.1"
},
"bin": {
"swagger-cli": "bin/swagger-cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/@apidevtools/swagger-cli/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@apidevtools/swagger-cli/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"dev": true,
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.1.tgz",
"integrity": "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "11.7.2",
"@apidevtools/openapi-schemas": "^2.1.0",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"call-me-maybe": "^1.0.2"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@apidevtools/swagger-parser/node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.7.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz",
"integrity": "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.15",
"js-yaml": "^4.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/philsturgeon"
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -3835,6 +4104,13 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"dev": true,
"license": "MIT"
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
@@ -3970,6 +4246,14 @@
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==",
"license": "MIT"
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -4967,6 +5251,21 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-draft-04": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
@@ -5549,6 +5848,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -7192,6 +7498,16 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
@@ -8600,6 +8916,16 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
@@ -13110,15 +13436,16 @@
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -13427,6 +13754,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
@@ -13809,9 +14144,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [
{
"type": "opencollective",
@@ -13826,10 +14161,11 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.2.0"
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -15367,6 +15703,15 @@
"node": ">= 0.10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pupa": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz",
@@ -16205,6 +16550,16 @@
"node": ">=0.10"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -16222,6 +16577,13 @@
"node": "*"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -16702,6 +17064,13 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -16980,9 +17349,10 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -17347,6 +17717,32 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
},
"node_modules/swagger-cli": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/swagger-cli/-/swagger-cli-4.0.4.tgz",
"integrity": "sha512-Cp8YYuLny3RJFQ4CvOBTaqmOOgYsem52dPx1xM5S4EUWFblIh2Q8atppMZvXKUr1e9xH5RwipYpmdUzdPcxWcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-cli": "4.0.4"
},
"bin": {
"swagger-cli": "swagger-cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.21.0.tgz",
"integrity": "sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
@@ -17949,14 +18345,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/uri-js/node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"engines": {
"node": ">=6"
}
},
"node_modules/url-loader": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
@@ -18610,6 +18998,13 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/widest-line": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",

View File

@@ -4,16 +4,18 @@
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"start": "node generate-swagger-ui.js && docusaurus start",
"build": "node generate-swagger-ui.js && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
"typecheck": "tsc",
"generate-swagger-ui": "node generate-swagger-ui.js"
},
"// Note": "The OpenAPI spec is stored in docs/static/openapi.json so it's accessible at /openapi.json in the deployed site",
"dependencies": {
"@docusaurus/core": "^3.7.0",
"@docusaurus/plugin-content-pages": "^3.7.0",
@@ -31,6 +33,8 @@
"@docusaurus/module-type-aliases": "^3.5.1",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.5.1",
"swagger-cli": "^4.0.4",
"swagger-ui-dist": "^5.21.0",
"typescript": "~5.8.3"
},
"browserslist": {
@@ -47,5 +51,6 @@
},
"engines": {
"node": ">=18.0"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -268,4 +268,4 @@ const sidebars: SidebarsConfig = {
],
};
export default sidebars;
export default sidebars;

15
docs/static/README.md vendored Normal file
View File

@@ -0,0 +1,15 @@
# Static Files for OpenHands Documentation
This directory contains static files that are copied directly to the build output of the Docusaurus documentation.
## OpenAPI Specification
The `openapi.json` file in this directory is the OpenAPI specification for the OpenHands API. It is copied to the build output and is accessible at `/openapi.json` in the deployed site.
This file is used by the Swagger UI interface, which is accessible at `/swagger-ui/` in the deployed site.
## Why is the OpenAPI spec in the static directory?
The OpenAPI specification is placed in the static directory so that it's accessible at a predictable URL in the deployed site. This allows the Swagger UI to reference it directly.
We only need one copy of the OpenAPI spec file, which is this one in the static directory.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 120 KiB

2085
docs/static/openapi.json vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,10 @@
# Run frontend checks
echo "Running frontend checks..."
cd frontend
npm run check-unlocalized-strings
npx lint-staged
# Run backend pre-commit
echo "Running backend pre-commit..."
cd ..
pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml

View File

@@ -45,7 +45,7 @@ describe("Empty state", () => {
it("should render suggestions if empty", () => {
const { store } = renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: {
chat: {
messages: [],
systemMessage: {
content: "",
@@ -76,7 +76,7 @@ describe("Empty state", () => {
it("should render the default suggestions", () => {
renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: {
chat: {
messages: [],
systemMessage: {
content: "",
@@ -114,7 +114,7 @@ describe("Empty state", () => {
const user = userEvent.setup();
const { store } = renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: {
chat: {
messages: [],
systemMessage: {
content: "",
@@ -151,7 +151,7 @@ describe("Empty state", () => {
const user = userEvent.setup();
const { rerender } = renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: {
chat: {
messages: [],
systemMessage: {
content: "",

View File

@@ -74,7 +74,8 @@ describe("RepoConnector", () => {
renderRepoConnector();
const dropdown = screen.getByTestId("repo-dropdown");
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await waitFor(() => {
@@ -98,7 +99,8 @@ describe("RepoConnector", () => {
const launchButton = screen.getByTestId("repo-launch-button");
expect(launchButton).toBeDisabled();
const dropdown = screen.getByTestId("repo-dropdown");
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
@@ -132,6 +134,14 @@ describe("RepoConnector", () => {
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
@@ -144,7 +154,9 @@ describe("RepoConnector", () => {
expect(createConversationSpy).not.toHaveBeenCalled();
// select a repository from the dropdown
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
const dropdown = await waitFor(() =>
within(repoConnector).getByTestId("repo-dropdown")
);
await userEvent.click(dropdown);
const repoOption = screen.getByText("rbren/polaris");
@@ -178,7 +190,8 @@ describe("RepoConnector", () => {
const launchButton = screen.getByTestId("repo-launch-button");
const dropdown = screen.getByTestId("repo-dropdown");
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));

View File

@@ -23,6 +23,7 @@ const MOCK_TASK_1: SuggestedTask = {
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
git_provider: "github",
};
const MOCK_TASK_2: SuggestedTask = {
@@ -30,6 +31,7 @@ const MOCK_TASK_2: SuggestedTask = {
repo: "repo2",
title: "Task 2",
task_type: "FAILING_CHECKS",
git_provider: "github",
};
const MOCK_TASK_3: SuggestedTask = {
@@ -37,6 +39,7 @@ const MOCK_TASK_3: SuggestedTask = {
repo: "repo3",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
git_provider: "gitlab",
};
const MOCK_TASK_4: SuggestedTask = {
@@ -44,6 +47,7 @@ const MOCK_TASK_4: SuggestedTask = {
repo: "repo4",
title: "Task 4",
task_type: "OPEN_ISSUE",
git_provider: "gitlab",
};
const MOCK_RESPOSITORIES: GitRepository[] = [
@@ -119,7 +123,11 @@ describe("TaskCard", () => {
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[0],
getMergeConflictPrompt(MOCK_TASK_1.issue_number, MOCK_TASK_1.repo),
getMergeConflictPrompt(
MOCK_TASK_1.git_provider,
MOCK_TASK_1.issue_number,
MOCK_TASK_1.repo,
),
[],
undefined,
);
@@ -135,7 +143,11 @@ describe("TaskCard", () => {
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[1],
getFailingChecksPrompt(MOCK_TASK_2.issue_number, MOCK_TASK_2.repo),
getFailingChecksPrompt(
MOCK_TASK_2.git_provider,
MOCK_TASK_2.issue_number,
MOCK_TASK_2.repo,
),
[],
undefined,
);
@@ -151,7 +163,11 @@ describe("TaskCard", () => {
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[2],
getUnresolvedCommentsPrompt(MOCK_TASK_3.issue_number, MOCK_TASK_3.repo),
getUnresolvedCommentsPrompt(
MOCK_TASK_3.git_provider,
MOCK_TASK_3.issue_number,
MOCK_TASK_3.repo,
),
[],
undefined,
);
@@ -167,7 +183,11 @@ describe("TaskCard", () => {
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[3],
getOpenIssuePrompt(MOCK_TASK_4.issue_number, MOCK_TASK_4.repo),
getOpenIssuePrompt(
MOCK_TASK_4.git_provider,
MOCK_TASK_4.issue_number,
MOCK_TASK_4.repo,
),
[],
undefined,
);

View File

@@ -43,10 +43,12 @@ describe("Settings Billing", () => {
renderSettingsScreen();
await waitFor(() => {
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
});
// Wait for the settings screen to be rendered
await screen.findByTestId("settings-screen");
// Then check that the navbar is not present
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
});
it("should render the navbar if SaaS mode", async () => {

View File

@@ -25,7 +25,6 @@ const mock_provider_tokens_are_set: Record<Provider, boolean> = {
describe("Settings Screen", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const resetSettingsSpy = vi.spyOn(OpenHands, "resetSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const { handleLogoutMock } = vi.hoisted(() => ({
@@ -67,7 +66,6 @@ describe("Settings Screen", () => {
// Use queryAllByText to handle multiple elements with the same text
expect(screen.queryAllByText("SETTINGS$LLM_SETTINGS")).not.toHaveLength(0);
screen.getByText("ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS");
screen.getByText("BUTTON$RESET_TO_DEFAULTS");
screen.getByText("BUTTON$SAVE");
});
});
@@ -542,54 +540,6 @@ describe("Settings Screen", () => {
});
});
test("resetting settings with no changes but having advanced enabled should hide the advanced items", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
});
renderSettingsScreen();
await toggleAdvancedSettings(user);
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
await user.click(resetButton);
// show modal
const modal = await screen.findByTestId("reset-modal");
expect(modal).toBeInTheDocument();
// Mock the settings that will be returned after reset
// This should be the default settings with no advanced settings enabled
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_base_url: "",
confirmation_mode: false,
security_analyzer: "",
});
// confirm reset
const confirmButton = within(modal).getByText("Reset");
await user.click(confirmButton);
await waitFor(() => {
expect(
screen.queryByTestId("llm-custom-model-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("base-url-input"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
});
});
it("should save if only confirmation mode is enabled", async () => {
const user = userEvent.setup();
renderSettingsScreen();
@@ -762,81 +712,6 @@ describe("Settings Screen", () => {
);
});
it("should reset the settings when the 'Reset to defaults' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderSettingsScreen();
const languageInput = await screen.findByTestId("language-input");
await user.click(languageInput);
const norskOption = await screen.findByText("Norsk");
await user.click(norskOption);
expect(languageInput).toHaveValue("Norsk");
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
await user.click(resetButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
// show modal
const modal = await screen.findByTestId("reset-modal");
expect(modal).toBeInTheDocument();
// confirm reset
const confirmButton = within(modal).getByText("Reset");
await user.click(confirmButton);
await waitFor(() => {
expect(resetSettingsSpy).toHaveBeenCalled();
});
// Mock the settings response after reset
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_base_url: "",
confirmation_mode: false,
security_analyzer: "",
});
// Wait for the mutation to complete and the modal to be removed
await waitFor(() => {
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
expect(
screen.queryByTestId("llm-custom-model-input"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
});
});
it("should cancel the reset when the 'Cancel' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderSettingsScreen();
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
await user.click(resetButton);
const modal = await screen.findByTestId("reset-modal");
expect(modal).toBeInTheDocument();
const cancelButton = within(modal).getByText("Cancel");
await user.click(cancelButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
});
it("should call handleCaptureConsent with true if the save is successful", async () => {
const user = userEvent.setup();
const handleCaptureConsentSpy = vi.spyOn(
@@ -1044,18 +919,5 @@ describe("Settings Screen", () => {
);
});
it("should not submit the unwanted fields when resetting", async () => {
const user = userEvent.setup();
renderSettingsScreen();
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
await user.click(resetButton);
const modal = await screen.findByTestId("reset-modal");
const confirmButton = within(modal).getByText("Reset");
await user.click(confirmButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
expect(resetSettingsSpy).toHaveBeenCalled();
});
});
});

View File

@@ -11,30 +11,35 @@ const rawTasks: SuggestedTask[] = [
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
git_provider: "github",
},
{
issue_number: 2,
repo: "repo1",
title: "Task 2",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
{
issue_number: 3,
repo: "repo2",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
git_provider: "github",
},
{
issue_number: 4,
repo: "repo2",
title: "Task 4",
task_type: "OPEN_ISSUE",
git_provider: "github",
},
{
issue_number: 5,
repo: "repo3",
title: "Task 5",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
];
@@ -47,12 +52,14 @@ const groupedTasks: SuggestedTaskGroup[] = [
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
git_provider: "github",
},
{
issue_number: 2,
repo: "repo1",
title: "Task 2",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
],
},
@@ -64,12 +71,14 @@ const groupedTasks: SuggestedTaskGroup[] = [
repo: "repo2",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
git_provider: "github",
},
{
issue_number: 4,
repo: "repo2",
title: "Task 4",
task_type: "OPEN_ISSUE",
git_provider: "github",
},
],
},
@@ -81,6 +90,7 @@ const groupedTasks: SuggestedTaskGroup[] = [
repo: "repo3",
title: "Task 5",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
],
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.33.0",
"version": "0.34.0",
"private": true,
"type": "module",
"engines": {
@@ -10,12 +10,12 @@
"@heroui/react": "2.7.6",
"@microlink/react-json-view": "^1.26.1",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.5.1",
"@react-router/serve": "^7.5.1",
"@react-router/node": "^7.5.2",
"@react-router/serve": "^7.5.2",
"@react-types/shared": "^3.29.0",
"@reduxjs/toolkit": "^2.7.0",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^7.1.0",
"@stripe/stripe-js": "^7.2.0",
"@tanstack/react-query": "^5.74.4",
"@vitejs/plugin-react": "^4.4.0",
"@xterm/addon-fit": "^0.10.0",
@@ -23,31 +23,31 @@
"axios": "^1.8.4",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.7.4",
"i18next": "^25.0.0",
"framer-motion": "^12.9.1",
"i18next": "^25.0.1",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.25",
"jose": "^6.0.10",
"lucide-react": "^0.501.0",
"lucide-react": "^0.503.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.236.2",
"posthog-js": "^1.236.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.4.1",
"react-i18next": "^15.5.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.5.1",
"react-router": "^7.5.2",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.2.0",
"vite": "^6.3.2",
"vite": "^6.3.3",
"web-vitals": "^3.5.2",
"ws": "^8.18.1"
},
@@ -82,7 +82,7 @@
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.52.0",
"@react-router/dev": "^7.5.1",
"@react-router/dev": "^7.5.2",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.73.3",
"@testing-library/dom": "^10.4.0",
@@ -97,7 +97,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/coverage-v8": "^3.1.2",
"autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",

View File

@@ -0,0 +1,49 @@
import { openHands } from "./open-hands-axios";
export interface ApiKey {
id: string;
name: string;
prefix: string;
created_at: string;
last_used_at: string | null;
}
export interface CreateApiKeyResponse {
id: string;
name: string;
key: string; // Full key, only returned once upon creation
prefix: string;
created_at: string;
}
class ApiKeysClient {
/**
* Get all API keys for the current user
*/
static async getApiKeys(): Promise<ApiKey[]> {
const { data } = await openHands.get<unknown>("/api/keys");
// Ensure we always return an array, even if the API returns something else
return Array.isArray(data) ? (data as ApiKey[]) : [];
}
/**
* Create a new API key
* @param name - A descriptive name for the API key
*/
static async createApiKey(name: string): Promise<CreateApiKeyResponse> {
const { data } = await openHands.post<CreateApiKeyResponse>("/api/keys", {
name,
});
return data;
}
/**
* Delete an API key
* @param id - The ID of the API key to delete
*/
static async deleteApiKey(id: string): Promise<void> {
await openHands.delete(`/api/keys/${id}`);
}
}
export default ApiKeysClient;

View File

@@ -199,14 +199,6 @@ class OpenHands {
return data.status === 200;
}
/**
* Reset user settings in server
*/
static async resetSettings(): Promise<boolean> {
const response = await openHands.post("/api/reset-settings");
return response.status === 200;
}
static async createCheckoutSession(amount: number): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-checkout-session",

View File

@@ -0,0 +1,138 @@
import { render, screen } from "@testing-library/react";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { RepositorySelectionForm } from "./repo-selection-form";
// Create mock functions
const mockUseUserRepositories = vi.fn();
const mockUseCreateConversation = vi.fn();
const mockUseIsCreatingConversation = vi.fn();
const mockUseTranslation = vi.fn();
const mockUseAuth = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
data: { pages: [{ data: [] }] },
isLoading: false,
isError: false,
});
mockUseCreateConversation.mockReturnValue({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
});
mockUseIsCreatingConversation.mockReturnValue(false);
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
// Mock the modules
vi.mock("#/hooks/query/use-user-repositories", () => ({
useUserRepositories: () => mockUseUserRepositories(),
}));
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
useCreateConversation: () => mockUseCreateConversation(),
}));
vi.mock("#/hooks/use-is-creating-conversation", () => ({
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => mockUseTranslation(),
}));
vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
describe("RepositorySelectionForm", () => {
const mockOnRepoSelection = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test("shows loading indicator when repositories are being fetched", () => {
// Setup loading state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
});
test("shows dropdown when repositories are loaded", () => {
// Setup loaded repositories
mockUseUserRepositories.mockReturnValue({
data: {
pages: [
{
data: [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
],
},
],
},
isLoading: false,
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
// Check if dropdown is displayed
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
});
test("shows error message when repository fetch fails", () => {
// Setup error state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error("Failed to fetch repositories"),
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
// Check if error message is displayed
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
expect(
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
@@ -11,12 +12,68 @@ interface RepositorySelectionFormProps {
onRepoSelection: (repoTitle: string | null) => void;
}
// Loading state component
function RepositoryLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-loading"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_REPOSITORIES")}</span>
</div>
);
}
// Error state component
function RepositoryErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-error"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}</span>
</div>
);
}
// Repository dropdown component
interface RepositoryDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
}
function RepositoryDropdown({
items,
onSelectionChange,
onInputChange,
}: RepositoryDropdownProps) {
return (
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder="Select a repo"
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
/>
);
}
export function RepositorySelectionForm({
onRepoSelection,
}: RepositorySelectionFormProps) {
const [selectedRepository, setSelectedRepository] =
React.useState<GitRepository | null>(null);
const { data: repositories } = useUserRepositories();
const {
data: repositories,
isLoading: isLoadingRepositories,
isError: isRepositoriesError,
} = useUserRepositories();
const {
mutate: createConversation,
isPending,
@@ -52,23 +109,39 @@ export function RepositorySelectionForm({
}
};
return (
<>
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder="Select a repo"
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
if (isLoadingRepositories) {
return <RepositoryLoadingState />;
}
if (isRepositoriesError) {
return <RepositoryErrorState />;
}
return (
<RepositoryDropdown
items={repositoriesItems || []}
wrapperClassName="max-w-[500px]"
onSelectionChange={handleRepoSelection}
onInputChange={handleInputChange}
/>
);
};
return (
<>
{renderRepositorySelector()}
<BrandButton
testId="repo-launch-button"
variant="primary"
type="button"
isDisabled={!selectedRepository || isCreatingConversation}
isDisabled={
!selectedRepository ||
isCreatingConversation ||
isLoadingRepositories ||
isRepositoriesError
}
onClick={() => createConversation({ selectedRepository })}
>
{!isCreatingConversation && "Launch"}

View File

@@ -1,48 +1,94 @@
import { Provider } from "#/types/settings";
import { SuggestedTaskType } from "./task.types";
// Helper function to get provider-specific terminology
const getProviderTerms = (git_provider: Provider) => {
if (git_provider === "gitlab") {
return {
requestType: "Merge Request",
requestTypeShort: "MR",
apiName: "GitLab API",
tokenEnvVar: "GITLAB_TOKEN",
ciSystem: "CI pipelines",
ciProvider: "GitLab",
requestVerb: "merge request",
};
}
return {
requestType: "Pull Request",
requestTypeShort: "PR",
apiName: "GitHub API",
tokenEnvVar: "GITHUB_TOKEN",
ciSystem: "GitHub Actions",
ciProvider: "GitHub",
requestVerb: "pull request",
};
};
export const getMergeConflictPrompt = (
git_provider: Provider,
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
) => {
const terms = getProviderTerms(git_provider);
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.`;
};
export const getFailingChecksPrompt = (
git_provider: Provider,
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then use the GitHub API to look at the GitHub Actions that are failing on the most recent commit. Try and reproduce the failure locally.
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the GitHub actions have run again. If they are still failing, repeat the process.`;
) => {
const terms = getProviderTerms(git_provider);
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
Then use the ${terms.apiName} to look at the ${terms.ciSystem} that are failing on the most recent commit. Try and reproduce the failure locally.
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the ${terms.ciProvider} ${terms.ciSystem.toLowerCase()} have run again. If they are still failing, repeat the process.`;
};
export const getUnresolvedCommentsPrompt = (
git_provider: Provider,
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then use the GitHub API to retrieve all the feedback on the PR so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
) => {
const terms = getProviderTerms(git_provider);
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
Then use the ${terms.apiName} to retrieve all the feedback on the ${terms.requestTypeShort} so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
};
export const getOpenIssuePrompt = (
git_provider: Provider,
issueNumber: number,
repo: string,
) => `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue
Use the GitHub API to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made
Finally, make the required changes and open up a pull request. Be sure to reference the issue in the PR description`;
) => {
const terms = getProviderTerms(git_provider);
return `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue.
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made.
Finally, make the required changes and open up a ${terms.requestVerb}. Be sure to reference the issue in the ${terms.requestTypeShort} description.`;
};
export const getPromptForQuery = (
git_provider: Provider,
type: SuggestedTaskType,
issueNumber: number,
repo: string,
) => {
switch (type) {
case "MERGE_CONFLICTS":
return getMergeConflictPrompt(issueNumber, repo);
return getMergeConflictPrompt(git_provider, issueNumber, repo);
case "FAILING_CHECKS":
return getFailingChecksPrompt(issueNumber, repo);
return getFailingChecksPrompt(git_provider, issueNumber, repo);
case "UNRESOLVED_COMMENTS":
return getUnresolvedCommentsPrompt(issueNumber, repo);
return getUnresolvedCommentsPrompt(git_provider, issueNumber, repo);
case "OPEN_ISSUE":
return getOpenIssuePrompt(issueNumber, repo);
return getOpenIssuePrompt(git_provider, issueNumber, repo);
default:
return "";
}

View File

@@ -6,6 +6,7 @@ import { cn } from "#/utils/utils";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { getPromptForQuery } from "./get-prompt-for-query";
import { TaskIssueNumber } from "./task-issue-number";
import { Provider } from "#/types/settings";
const getTaskTypeMap = (
t: (key: string) => string,
@@ -26,18 +27,21 @@ export function TaskCard({ task }: TaskCardProps) {
const isCreatingConversation = useIsCreatingConversation();
const { t } = useTranslation();
const getRepo = (repo: string) => {
const getRepo = (repo: string, git_provider: Provider) => {
const repositoriesList = repositories?.pages.flatMap((page) => page.data);
const selectedRepo = repositoriesList?.find(
(repository) => repository.full_name === repo,
(repository) =>
repository.full_name === repo &&
repository.git_provider === git_provider,
);
return selectedRepo;
};
const handleLaunchConversation = () => {
const repo = getRepo(task.repo);
const repo = getRepo(task.repo, task.git_provider);
const query = getPromptForQuery(
task.git_provider,
task.task_type,
task.issue_number,
task.repo,
@@ -49,8 +53,16 @@ export function TaskCard({ task }: TaskCardProps) {
});
};
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
const href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
// Determine the correct URL format based on git provider
let href: string;
if (task.git_provider === "gitlab") {
const issueType =
task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests";
href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`;
} else {
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
}
return (
<li className="py-3 border-b border-[#717888] flex items-center pr-6">

View File

@@ -1,3 +1,5 @@
import { Provider } from "#/types/settings";
export type SuggestedTaskType =
| "MERGE_CONFLICTS"
| "FAILING_CHECKS"
@@ -5,6 +7,7 @@ export type SuggestedTaskType =
| "OPEN_ISSUE"; // This is a task type identifier, not a UI string
export interface SuggestedTask {
git_provider: Provider;
issue_number: number;
repo: string;
title: string;

View File

@@ -40,10 +40,6 @@ export function PaymentForm() {
data-testid="billing-settings"
className="flex flex-col gap-6 px-11 py-9"
>
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.PAYMENT$MANAGE_CREDITS)}
</h2>
<div
className={cn(
"flex items-center justify-between w-[680px] bg-[#7F7445] rounded px-3 py-2",
@@ -52,7 +48,7 @@ export function PaymentForm() {
>
<div className="flex items-center gap-2">
<MoneyIcon width={22} height={14} />
<span>Balance</span>
<span>{t(I18nKey.PAYMENT$MANAGE_CREDITS)}</span>
</div>
{!isLoading && (
<span data-testid="user-balance">${Number(balance).toFixed(2)}</span>

View File

@@ -0,0 +1,33 @@
import React, { ReactNode } from "react";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
interface ApiKeyModalBaseProps {
isOpen: boolean;
title: string;
width?: string;
children: ReactNode;
footer: ReactNode;
}
export function ApiKeyModalBase({
isOpen,
title,
width = "500px",
children,
footer,
}: ApiKeyModalBaseProps) {
if (!isOpen) return null;
return (
<ModalBackdrop>
<div
className="bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-tertiary"
style={{ width }}
>
<h3 className="text-xl font-bold">{title}</h3>
{children}
<div className="w-full flex gap-2 mt-2">{footer}</div>
</div>
</ModalBackdrop>
);
}

View File

@@ -0,0 +1,146 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { CreateApiKeyModal } from "./create-api-key-modal";
import { DeleteApiKeyModal } from "./delete-api-key-modal";
import { NewApiKeyModal } from "./new-api-key-modal";
import { useApiKeys } from "#/hooks/query/use-api-keys";
export function ApiKeysManager() {
const { t } = useTranslation();
const { data: apiKeys = [], isLoading, error } = useApiKeys();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [keyToDelete, setKeyToDelete] = useState<ApiKey | null>(null);
const [newlyCreatedKey, setNewlyCreatedKey] =
useState<CreateApiKeyResponse | null>(null);
const [showNewKeyModal, setShowNewKeyModal] = useState(false);
// Display error toast if the query fails
if (error) {
displayErrorToast(t(I18nKey.ERROR$GENERIC));
}
const handleKeyCreated = (newKey: CreateApiKeyResponse) => {
setNewlyCreatedKey(newKey);
setCreateModalOpen(false);
setShowNewKeyModal(true);
};
const handleCloseCreateModal = () => {
setCreateModalOpen(false);
};
const handleCloseDeleteModal = () => {
setDeleteModalOpen(false);
setKeyToDelete(null);
};
const handleCloseNewKeyModal = () => {
setShowNewKeyModal(false);
setNewlyCreatedKey(null);
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "Never";
return new Date(dateString).toLocaleString();
};
return (
<>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<BrandButton
type="button"
variant="primary"
onClick={() => setCreateModalOpen(true)}
>
{t(I18nKey.SETTINGS$CREATE_API_KEY)}
</BrandButton>
</div>
<p className="text-sm text-gray-300">
{t(I18nKey.SETTINGS$API_KEYS_DESCRIPTION)}
</p>
{isLoading && (
<div className="flex justify-center p-4">
<LoadingSpinner size="large" />
</div>
)}
{!isLoading && Array.isArray(apiKeys) && apiKeys.length > 0 && (
<div className="border border-tertiary rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-base-tertiary">
<tr>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$NAME)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$CREATED_AT)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$LAST_USED)}
</th>
<th className="text-right p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$ACTIONS)}
</th>
</tr>
</thead>
<tbody>
{apiKeys.map((key) => (
<tr key={key.id} className="border-t border-tertiary">
<td className="p-3 text-sm">{key.name}</td>
<td className="p-3 text-sm">
{formatDate(key.created_at)}
</td>
<td className="p-3 text-sm">
{formatDate(key.last_used_at)}
</td>
<td className="p-3 text-right">
<button
type="button"
className="underline"
onClick={() => {
setKeyToDelete(key);
setDeleteModalOpen(true);
}}
>
{t(I18nKey.BUTTON$DELETE)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Create API Key Modal */}
<CreateApiKeyModal
isOpen={createModalOpen}
onClose={handleCloseCreateModal}
onKeyCreated={handleKeyCreated}
/>
{/* Delete API Key Modal */}
<DeleteApiKeyModal
isOpen={deleteModalOpen}
keyToDelete={keyToDelete}
onClose={handleCloseDeleteModal}
/>
{/* Show New API Key Modal */}
<NewApiKeyModal
isOpen={showNewKeyModal}
newlyCreatedKey={newlyCreatedKey}
onClose={handleCloseNewKeyModal}
/>
</>
);
}

View File

@@ -2,7 +2,7 @@ import { cn } from "#/utils/utils";
interface BrandButtonProps {
testId?: string;
variant: "primary" | "secondary";
variant: "primary" | "secondary" | "danger";
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
isDisabled?: boolean;
className?: string;
@@ -32,6 +32,7 @@ export function BrandButton({
"w-fit p-2 text-sm rounded disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80",
variant === "primary" && "bg-primary text-[#0D0F11]",
variant === "secondary" && "border border-primary text-primary",
variant === "danger" && "bg-red-600 text-white hover:bg-red-700",
startContent && "flex items-center justify-center gap-2",
className,
)}

View File

@@ -0,0 +1,101 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { CreateApiKeyResponse } from "#/api/api-keys";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ApiKeyModalBase } from "./api-key-modal-base";
import { useCreateApiKey } from "#/hooks/mutation/use-create-api-key";
interface CreateApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
onKeyCreated: (newKey: CreateApiKeyResponse) => void;
}
export function CreateApiKeyModal({
isOpen,
onClose,
onKeyCreated,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const [newKeyName, setNewKeyName] = useState("");
const createApiKeyMutation = useCreateApiKey();
const handleCreateKey = async () => {
if (!newKeyName.trim()) {
displayErrorToast(t(I18nKey.ERROR$REQUIRED_FIELD));
return;
}
try {
const newKey = await createApiKeyMutation.mutateAsync(newKeyName);
onKeyCreated(newKey);
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_CREATED));
setNewKeyName("");
} catch (error) {
displayErrorToast(t(I18nKey.ERROR$GENERIC));
}
};
const handleCancel = () => {
setNewKeyName("");
onClose();
};
const modalFooter = (
<>
<BrandButton
type="button"
variant="primary"
className="grow"
onClick={handleCreateKey}
isDisabled={createApiKeyMutation.isPending || !newKeyName.trim()}
>
{createApiKeyMutation.isPending ? (
<LoadingSpinner size="small" />
) : (
t(I18nKey.BUTTON$CREATE)
)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={handleCancel}
isDisabled={createApiKeyMutation.isPending}
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</>
);
return (
<ApiKeyModalBase
isOpen={isOpen}
title={t(I18nKey.SETTINGS$CREATE_API_KEY)}
footer={modalFooter}
>
<div data-testid="create-api-key-modal">
<p className="text-sm text-gray-300">
{t(I18nKey.SETTINGS$CREATE_API_KEY_DESCRIPTION)}
</p>
<SettingsInput
testId="api-key-name-input"
label={t(I18nKey.SETTINGS$NAME)}
placeholder={t(I18nKey.SETTINGS$API_KEY_NAME_PLACEHOLDER)}
value={newKeyName}
onChange={(value) => setNewKeyName(value)}
className="w-full mt-4"
type="text"
/>
</div>
</ApiKeyModalBase>
);
}

View File

@@ -0,0 +1,84 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ApiKey } from "#/api/api-keys";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ApiKeyModalBase } from "./api-key-modal-base";
import { useDeleteApiKey } from "#/hooks/mutation/use-delete-api-key";
interface DeleteApiKeyModalProps {
isOpen: boolean;
keyToDelete: ApiKey | null;
onClose: () => void;
}
export function DeleteApiKeyModal({
isOpen,
keyToDelete,
onClose,
}: DeleteApiKeyModalProps) {
const { t } = useTranslation();
const deleteApiKeyMutation = useDeleteApiKey();
const handleDeleteKey = async () => {
if (!keyToDelete) return;
try {
await deleteApiKeyMutation.mutateAsync(keyToDelete.id);
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_DELETED));
onClose();
} catch (error) {
displayErrorToast(t(I18nKey.ERROR$GENERIC));
}
};
if (!keyToDelete) return null;
const modalFooter = (
<>
<BrandButton
type="button"
variant="danger"
className="grow"
onClick={handleDeleteKey}
isDisabled={deleteApiKeyMutation.isPending}
>
{deleteApiKeyMutation.isPending ? (
<LoadingSpinner size="small" />
) : (
t(I18nKey.BUTTON$DELETE)
)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={onClose}
isDisabled={deleteApiKeyMutation.isPending}
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</>
);
return (
<ApiKeyModalBase
isOpen={isOpen && !!keyToDelete}
title={t(I18nKey.SETTINGS$DELETE_API_KEY)}
footer={modalFooter}
>
<div data-testid="delete-api-key-modal">
<p className="text-sm">
{t(I18nKey.SETTINGS$DELETE_API_KEY_CONFIRMATION, {
name: keyToDelete.name,
})}
</p>
</div>
</ApiKeyModalBase>
);
}

View File

@@ -0,0 +1,61 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { CreateApiKeyResponse } from "#/api/api-keys";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { ApiKeyModalBase } from "./api-key-modal-base";
interface NewApiKeyModalProps {
isOpen: boolean;
newlyCreatedKey: CreateApiKeyResponse | null;
onClose: () => void;
}
export function NewApiKeyModal({
isOpen,
newlyCreatedKey,
onClose,
}: NewApiKeyModalProps) {
const { t } = useTranslation();
const handleCopyToClipboard = () => {
if (newlyCreatedKey) {
navigator.clipboard.writeText(newlyCreatedKey.key);
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_COPIED));
}
};
if (!newlyCreatedKey) return null;
const modalFooter = (
<>
<BrandButton
type="button"
variant="primary"
onClick={handleCopyToClipboard}
>
{t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)}
</BrandButton>
<BrandButton type="button" variant="secondary" onClick={onClose}>
{t(I18nKey.BUTTON$CLOSE)}
</BrandButton>
</>
);
return (
<ApiKeyModalBase
isOpen={isOpen && !!newlyCreatedKey}
title={t(I18nKey.SETTINGS$API_KEY_CREATED)}
width="600px"
footer={modalFooter}
>
<div data-testid="new-api-key-modal">
<p className="text-sm">{t(I18nKey.SETTINGS$API_KEY_WARNING)}</p>
<div className="bg-base-tertiary p-4 rounded-md font-mono text-sm break-all mt-4">
{newlyCreatedKey.key}
</div>
</div>
</ApiKeyModalBase>
);
}

View File

@@ -7,6 +7,7 @@ interface SettingsInputProps {
label: string;
type: React.HTMLInputTypeAttribute;
defaultValue?: string;
value?: string;
placeholder?: string;
showOptionalTag?: boolean;
isDisabled?: boolean;
@@ -24,6 +25,7 @@ export function SettingsInput({
label,
type,
defaultValue,
value,
placeholder,
showOptionalTag,
isDisabled,
@@ -43,11 +45,12 @@ export function SettingsInput({
</div>
<input
data-testid={testId}
onChange={(e) => onChange?.(e.target.value)}
onChange={(e) => onChange && onChange(e.target.value)}
name={name}
disabled={isDisabled}
type={type}
defaultValue={defaultValue}
value={value}
placeholder={placeholder}
min={min}
max={max}

View File

@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import ApiKeysClient, { CreateApiKeyResponse } from "#/api/api-keys";
import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
export function useCreateApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (name: string): Promise<CreateApiKeyResponse> =>
ApiKeysClient.createApiKey(name),
onSuccess: () => {
// Invalidate the API keys query to trigger a refetch
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
},
});
}

View File

@@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import ApiKeysClient from "#/api/api-keys";
import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
export function useDeleteApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string): Promise<void> => {
await ApiKeysClient.deleteApiKey(id);
},
onSuccess: () => {
// Invalidate the API keys query to trigger a refetch
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
},
});
}

View File

@@ -4,15 +4,7 @@ import OpenHands from "#/api/open-hands";
import { PostSettings, PostApiSettings } from "#/types/settings";
import { useSettings } from "../query/use-settings";
const saveSettingsMutationFn = async (
settings: Partial<PostSettings> | null,
) => {
// If settings is null, we're resetting
if (settings === null) {
await OpenHands.resetSettings();
return;
}
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
const apiSettings: Partial<PostApiSettings> = {
llm_model: settings.LLM_MODEL,
llm_base_url: settings.LLM_BASE_URL,
@@ -39,12 +31,7 @@ export const useSaveSettings = () => {
const { data: currentSettings } = useSettings();
return useMutation({
mutationFn: async (settings: Partial<PostSettings> | null) => {
if (settings === null) {
await saveSettingsMutationFn(null);
return;
}
mutationFn: async (settings: Partial<PostSettings>) => {
const newSettings = { ...currentSettings, ...settings };
await saveSettingsMutationFn(newSettings);
},

View File

@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import ApiKeysClient from "#/api/api-keys";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const API_KEYS_QUERY_KEY = "api-keys";
export function useApiKeys() {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
return useQuery({
queryKey: [API_KEYS_QUERY_KEY],
enabled: providersAreSet && config?.APP_MODE === "saas",
queryFn: async () => {
const keys = await ApiKeysClient.getApiKeys();
return Array.isArray(keys) ? keys : [];
},
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
}

View File

@@ -6,6 +6,8 @@ export enum I18nKey {
HOME$NOT_SURE_HOW_TO_START = "HOME$NOT_SURE_HOW_TO_START",
HOME$CONNECT_TO_REPOSITORY = "HOME$CONNECT_TO_REPOSITORY",
HOME$LOADING = "HOME$LOADING",
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
HOME$OPEN_ISSUE = "HOME$OPEN_ISSUE",
HOME$FIX_FAILING_CHECKS = "HOME$FIX_FAILING_CHECKS",
HOME$RESOLVE_MERGE_CONFLICTS = "HOME$RESOLVE_MERGE_CONFLICTS",
@@ -80,7 +82,6 @@ export enum I18nKey {
API$DONT_KNOW_KEY = "API$DONT_KNOW_KEY",
BUTTON$SAVE = "BUTTON$SAVE",
BUTTON$CLOSE = "BUTTON$CLOSE",
BUTTON$RESET_TO_DEFAULTS = "BUTTON$RESET_TO_DEFAULTS",
MODAL$CONFIRM_RESET_TITLE = "MODAL$CONFIRM_RESET_TITLE",
MODAL$CONFIRM_RESET_MESSAGE = "MODAL$CONFIRM_RESET_MESSAGE",
MODAL$END_SESSION_TITLE = "MODAL$END_SESSION_TITLE",
@@ -267,6 +268,27 @@ export enum I18nKey {
SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS",
SETTINGS$SAVED = "SETTINGS$SAVED",
SETTINGS$RESET = "SETTINGS$RESET",
SETTINGS$API_KEYS = "SETTINGS$API_KEYS",
SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION",
SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY",
SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION",
SETTINGS$DELETE_API_KEY = "SETTINGS$DELETE_API_KEY",
SETTINGS$DELETE_API_KEY_CONFIRMATION = "SETTINGS$DELETE_API_KEY_CONFIRMATION",
SETTINGS$NO_API_KEYS = "SETTINGS$NO_API_KEYS",
SETTINGS$NAME = "SETTINGS$NAME",
SETTINGS$KEY_PREFIX = "SETTINGS$KEY_PREFIX",
SETTINGS$CREATED_AT = "SETTINGS$CREATED_AT",
SETTINGS$LAST_USED = "SETTINGS$LAST_USED",
SETTINGS$ACTIONS = "SETTINGS$ACTIONS",
SETTINGS$API_KEY_CREATED = "SETTINGS$API_KEY_CREATED",
SETTINGS$API_KEY_DELETED = "SETTINGS$API_KEY_DELETED",
SETTINGS$API_KEY_WARNING = "SETTINGS$API_KEY_WARNING",
SETTINGS$API_KEY_COPIED = "SETTINGS$API_KEY_COPIED",
SETTINGS$API_KEY_NAME_PLACEHOLDER = "SETTINGS$API_KEY_NAME_PLACEHOLDER",
BUTTON$CREATE = "BUTTON$CREATE",
BUTTON$DELETE = "BUTTON$DELETE",
BUTTON$COPY_TO_CLIPBOARD = "BUTTON$COPY_TO_CLIPBOARD",
ERROR$REQUIRED_FIELD = "ERROR$REQUIRED_FIELD",
PLANNER$EMPTY_MESSAGE = "PLANNER$EMPTY_MESSAGE",
FEEDBACK$PUBLIC_LABEL = "FEEDBACK$PUBLIC_LABEL",
FEEDBACK$PRIVATE_LABEL = "FEEDBACK$PRIVATE_LABEL",
@@ -298,7 +320,6 @@ export enum I18nKey {
SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL = "SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL",
SETTINGS_FORM$SAVE_LABEL = "SETTINGS_FORM$SAVE_LABEL",
SETTINGS_FORM$CLOSE_LABEL = "SETTINGS_FORM$CLOSE_LABEL",
SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL = "SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL",
SETTINGS_FORM$CANCEL_LABEL = "SETTINGS_FORM$CANCEL_LABEL",
SETTINGS_FORM$END_SESSION_LABEL = "SETTINGS_FORM$END_SESSION_LABEL",
SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE = "SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE",
@@ -317,6 +338,7 @@ export enum I18nKey {
STATUS$LLM_RETRY = "STATUS$LLM_RETRY",
AGENT_ERROR$BAD_ACTION = "AGENT_ERROR$BAD_ACTION",
AGENT_ERROR$ACTION_TIMEOUT = "AGENT_ERROR$ACTION_TIMEOUT",
AGENT_ERROR$TOO_MANY_CONVERSATIONS = "AGENT_ERROR$TOO_MANY_CONVERSATIONS",
PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL",
PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL",
PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL",

View File

@@ -89,6 +89,36 @@
"tr": "Yükleniyor...",
"de": "Wird geladen..."
},
"HOME$LOADING_REPOSITORIES": {
"en": "Loading repositories...",
"ja": "リポジトリを読み込み中...",
"zh-CN": "加载仓库中...",
"zh-TW": "載入儲存庫中...",
"ko-KR": "저장소 로딩 중...",
"no": "Laster repositories...",
"it": "Caricamento repository in corso...",
"pt": "Carregando repositórios...",
"es": "Cargando repositorios...",
"ar": "جار تحميل المستودعات...",
"fr": "Chargement des dépôts...",
"tr": "Depolar yükleniyor...",
"de": "Repositories werden geladen..."
},
"HOME$FAILED_TO_LOAD_REPOSITORIES": {
"en": "Failed to load repositories",
"ja": "リポジトリの読み込みに失敗しました",
"zh-CN": "加载仓库失败",
"zh-TW": "載入儲存庫失敗",
"ko-KR": "저장소 로딩 실패",
"no": "Kunne ikke laste repositories",
"it": "Impossibile caricare i repository",
"pt": "Falha ao carregar repositórios",
"es": "Error al cargar repositorios",
"ar": "فشل في تحميل المستودعات",
"fr": "Échec du chargement des dépôts",
"tr": "Depolar yüklenemedi",
"de": "Fehler beim Laden der Repositories"
},
"HOME$OPEN_ISSUE": {
"en": "Open issue",
"ja": "オープンな課題",
@@ -1201,21 +1231,6 @@
"tr": "Kapat",
"de": "Schließen"
},
"BUTTON$RESET_TO_DEFAULTS": {
"en": "Reset to defaults",
"ja": "デフォルトにリセット",
"zh-CN": "重置为默认值",
"zh-TW": "還原為預設值",
"ko-KR": "기본값으로 재설정",
"no": "Tilbakestill til standard",
"it": "Ripristina valori predefiniti",
"pt": "Restaurar padrões",
"es": "Restablecer valores predeterminados",
"ar": "إعادة التعيين إلى الإعدادات الافتراضية",
"fr": "Réinitialiser aux valeurs par défaut",
"tr": "Varsayılanlara sıfırla",
"de": "Auf Standardwerte zurücksetzen"
},
"MODAL$CONFIRM_RESET_TITLE": {
"en": "Are you sure?",
"ja": "本当によろしいですか?",
@@ -3983,6 +3998,69 @@
"tr": "Ayarlar sıfırlandı",
"de": "Einstellungen zurückgesetzt"
},
"SETTINGS$API_KEYS": {
"en": "API Keys"
},
"SETTINGS$API_KEYS_DESCRIPTION": {
"en": "API keys allow you to authenticate with the OpenHands API programmatically. Keep your API keys secure; anyone with your API key can access your account."
},
"SETTINGS$CREATE_API_KEY": {
"en": "Create API Key"
},
"SETTINGS$CREATE_API_KEY_DESCRIPTION": {
"en": "Give your API key a descriptive name to help you identify it later."
},
"SETTINGS$DELETE_API_KEY": {
"en": "Delete API Key"
},
"SETTINGS$DELETE_API_KEY_CONFIRMATION": {
"en": "Are you sure you want to delete the API key \"{{name}}\"? This action cannot be undone."
},
"SETTINGS$NO_API_KEYS": {
"en": "You don't have any API keys yet. Create one to get started."
},
"SETTINGS$NAME": {
"en": "Name"
},
"SETTINGS$KEY_PREFIX": {
"en": "Key Prefix"
},
"SETTINGS$CREATED_AT": {
"en": "Created"
},
"SETTINGS$LAST_USED": {
"en": "Last Used"
},
"SETTINGS$ACTIONS": {
"en": "Actions"
},
"SETTINGS$API_KEY_CREATED": {
"en": "API Key Created"
},
"SETTINGS$API_KEY_DELETED": {
"en": "API key deleted successfully"
},
"SETTINGS$API_KEY_WARNING": {
"en": "This is the only time your API key will be displayed. Please copy it now and store it securely."
},
"SETTINGS$API_KEY_COPIED": {
"en": "API key copied to clipboard"
},
"SETTINGS$API_KEY_NAME_PLACEHOLDER": {
"en": "My API Key"
},
"BUTTON$CREATE": {
"en": "Create"
},
"BUTTON$DELETE": {
"en": "Delete"
},
"BUTTON$COPY_TO_CLIPBOARD": {
"en": "Copy to Clipboard"
},
"ERROR$REQUIRED_FIELD": {
"en": "This field is required"
},
"PLANNER$EMPTY_MESSAGE": {
"en": "No plan created.",
"zh-CN": "计划未创建",
@@ -4448,21 +4526,6 @@
"pt": "Fechar",
"tr": "Kapat"
},
"SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL": {
"en": "Reset to defaults",
"es": "Reiniciar valores por defect",
"zh-CN": "重置为默认值",
"zh-TW": "還原為預設值",
"ko-KR": "기본값으로 재설정",
"ja": "デフォルトに戻す",
"no": "Tilbakestill til standardverdier",
"ar": "إعادة التعيين إلى الإعدادات الافتراضية",
"de": "Auf Standardwerte zurücksetzen",
"fr": "Réinitialiser aux valeurs par défaut",
"it": "Ripristina valori predefiniti",
"pt": "Restaurar padrões",
"tr": "Varsayılanlara sıfırla"
},
"SETTINGS_FORM$CANCEL_LABEL": {
"en": "Cancel",
"es": "Cancelar",
@@ -4733,6 +4796,9 @@
"es": "La acción expiró",
"tr": "İşlem zaman aşımına uğradı"
},
"AGENT_ERROR$TOO_MANY_CONVERSATIONS": {
"en": "Too many conversations at once."
},
"PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": {
"en": "Connect to GitHub",
"es": "Conectar a GitHub",

View File

@@ -7,6 +7,7 @@ const TASKS_1: SuggestedTask[] = [
title: "Fix merge conflicts",
repo: "octocat/hello-world",
task_type: "MERGE_CONFLICTS",
git_provider: "github",
},
];
@@ -16,54 +17,63 @@ const TASKS_2: SuggestedTask[] = [
title: "Fix broken CI checks",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
{
issue_number: 281,
title: "Fix issue",
repo: "octocat/earth",
task_type: "UNRESOLVED_COMMENTS",
git_provider: "github",
},
{
issue_number: 293,
title: "Update documentation",
repo: "octocat/earth",
task_type: "OPEN_ISSUE",
git_provider: "github",
},
{
issue_number: 305,
title: "Refactor user service",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
{
issue_number: 312,
title: "Fix styling bug",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
{
issue_number: 327,
title: "Add unit tests",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
{
issue_number: 331,
title: "Implement dark mode",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
{
issue_number: 345,
title: "Optimize build process",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
{
issue_number: 352,
title: "Update dependencies",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
];

View File

@@ -11,6 +11,7 @@ export default [
route("settings", "routes/settings.tsx", [
index("routes/account-settings.tsx"),
route("billing", "routes/billing.tsx"),
route("api-keys", "routes/api-keys.tsx"),
]),
route("conversations/:conversationId", "routes/conversation.tsx", [
index("routes/editor.tsx"),

View File

@@ -9,7 +9,6 @@ import { SettingsDropdownInput } from "#/components/features/settings/settings-d
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
@@ -95,8 +94,6 @@ function AccountSettings() {
>(isAdvancedSettingsSet ? "advanced" : "basic");
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
React.useState(!!settings?.SECURITY_ANALYZER);
const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] =
React.useState(false);
const formRef = React.useRef<HTMLFormElement>(null);
@@ -180,16 +177,6 @@ function AccountSettings() {
});
};
const handleReset = () => {
saveSettings(null, {
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$RESET));
setResetSettingsModalIsOpen(false);
setLlmConfigMode("basic");
},
});
};
React.useEffect(() => {
// If settings is still loading by the time the state is set, it will always
// default to basic settings. This is a workaround to ensure the correct
@@ -527,13 +514,6 @@ function AccountSettings() {
</form>
<footer className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
type="button"
variant="secondary"
onClick={() => setResetSettingsModalIsOpen(true)}
>
{t(I18nKey.BUTTON$RESET_TO_DEFAULTS)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
@@ -544,40 +524,6 @@ function AccountSettings() {
{t(I18nKey.BUTTON$SAVE)}
</BrandButton>
</footer>
{resetSettingsModalIsOpen && (
<ModalBackdrop>
<div
data-testid="reset-modal"
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
>
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
<div className="w-full flex gap-2">
<BrandButton
type="button"
variant="primary"
className="grow"
onClick={() => {
handleReset();
}}
>
Reset
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={() => {
setResetSettingsModalIsOpen(false);
}}
>
Cancel
</BrandButton>
</div>
</div>
</ModalBackdrop>
)}
</>
);
}

View File

@@ -0,0 +1,12 @@
import React from "react";
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
function ApiKeysScreen() {
return (
<div className="flex flex-col grow overflow-auto p-11">
<ApiKeysManager />
</div>
);
}
export default ApiKeysScreen;

View File

@@ -1,25 +1,13 @@
import { redirect, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import React from "react";
import { useTranslation } from "react-i18next";
import { PaymentForm } from "#/components/features/payment/payment-form";
import { GetConfigResponse } from "#/api/open-hands.types";
import { queryClient } from "#/entry.client";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
export const clientLoader = async () => {
const config = queryClient.getQueryData<GetConfigResponse>(["config"]);
if (config?.APP_MODE !== "saas" || !config.FEATURE_FLAGS.ENABLE_BILLING) {
return redirect("/settings");
}
return null;
};
function BillingSettingsScreen() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();

View File

@@ -9,7 +9,6 @@ function SettingsScreen() {
const { t } = useTranslation();
const { data: config } = useConfig();
const isSaas = config?.APP_MODE === "saas";
const billingIsEnabled = config?.FEATURE_FLAGS.ENABLE_BILLING;
return (
<main
@@ -21,7 +20,7 @@ function SettingsScreen() {
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
</header>
{isSaas && billingIsEnabled && (
{isSaas && (
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-11 border-b border-tertiary"
@@ -29,6 +28,7 @@ function SettingsScreen() {
{[
{ to: "/settings", text: "Account" },
{ to: "/settings/billing", text: "Credits" },
{ to: "/settings/api-keys", text: "API Keys" },
].map(({ to, text }) => (
<NavLink
end

View File

@@ -14,14 +14,16 @@ export function groupSuggestedTasks(
const groupsMap: Record<string, SuggestedTaskGroup> = {};
for (const task of tasks) {
if (!groupsMap[task.repo]) {
groupsMap[task.repo] = {
title: task.repo,
const groupKey = `${task.repo}`;
if (!groupsMap[groupKey]) {
groupsMap[groupKey] = {
title: groupKey,
tasks: [],
};
}
groupsMap[task.repo].tasks.push(task);
groupsMap[groupKey].tasks.push(task);
}
return Object.values(groupsMap);

View File

@@ -95,20 +95,20 @@ class CodeActAgent(Agent):
self.response_to_actions_fn = codeact_function_calling.response_to_actions
def _get_tools(self) -> list[ChatCompletionToolParam]:
SIMPLIFIED_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1']
# For these models, we use short tool descriptions ( < 1024 tokens)
# to avoid hitting the OpenAI token limit for tool descriptions.
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1', 'o4']
use_simplified_tool_desc = False
use_short_tool_desc = False
if self.llm is not None:
use_simplified_tool_desc = any(
use_short_tool_desc = any(
model_substr in self.llm.config.model
for model_substr in SIMPLIFIED_TOOL_DESCRIPTION_LLM_SUBSTRS
for model_substr in SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS
)
tools = []
if self.config.enable_cmd:
tools.append(
create_cmd_run_tool(use_simplified_description=use_simplified_tool_desc)
)
tools.append(create_cmd_run_tool(use_short_description=use_short_tool_desc))
if self.config.enable_think:
tools.append(ThinkTool)
if self.config.enable_finish:
@@ -123,7 +123,7 @@ class CodeActAgent(Agent):
elif self.config.enable_editor:
tools.append(
create_str_replace_editor_tool(
use_simplified_description=use_simplified_tool_desc
use_short_description=use_short_tool_desc
)
)
return tools

View File

@@ -199,7 +199,7 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
# ================================================
elif tool_call.function.name.endswith(MCPClientTool.postfix()):
action = McpAction(
name=tool_call.function.name.rstrip(MCPClientTool.postfix()),
name=tool_call.function.name.removesuffix(MCPClientTool.postfix()),
arguments=tool_call.function.arguments,
)
else:

View File

@@ -22,19 +22,17 @@ _DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a
* Output truncation: If the output exceeds a maximum length, it will be truncated before being returned.
"""
_SIMPLIFIED_BASH_DESCRIPTION = """Execute a bash command in the terminal.
_SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
* Interact with running process: If a bash command returns exit code `-1`, this means the process is not yet finished. By setting `is_input` to `true`, the assistant can interact with the running process and send empty `command` to retrieve any additional logs, or send additional text (set `command` to the text) to STDIN of the running process, or send command like `C-c` (Ctrl+C), `C-d` (Ctrl+D), `C-z` (Ctrl+Z) to interrupt the process.
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together."""
def create_cmd_run_tool(
use_simplified_description: bool = False,
use_short_description: bool = False,
) -> ChatCompletionToolParam:
description = (
_SIMPLIFIED_BASH_DESCRIPTION
if use_simplified_description
else _DETAILED_BASH_DESCRIPTION
_SHORT_BASH_DESCRIPTION if use_short_description else _DETAILED_BASH_DESCRIPTION
)
return ChatCompletionToolParam(
type='function',

View File

@@ -31,7 +31,7 @@ CRITICAL REQUIREMENTS FOR USING THIS TOOL:
Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.
"""
_SIMPLIFIED_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
_SHORT_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
* State is persistent across command calls and discussions with the user
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
* The `create` command cannot be used if the specified `path` already exists as a file
@@ -45,11 +45,11 @@ Notes for using the `str_replace` command:
def create_str_replace_editor_tool(
use_simplified_description: bool = False,
use_short_description: bool = False,
) -> ChatCompletionToolParam:
description = (
_SIMPLIFIED_STR_REPLACE_EDITOR_DESCRIPTION
if use_simplified_description
_SHORT_STR_REPLACE_EDITOR_DESCRIPTION
if use_short_description
else _DETAILED_STR_REPLACE_EDITOR_DESCRIPTION
)
return ChatCompletionToolParam(

View File

@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from typing import Any
from openhands.events.action import Action
@@ -9,7 +10,7 @@ class ActionParseError(Exception):
def __init__(self, error: str):
self.error = error
def __str__(self):
def __str__(self) -> str:
return self.error
@@ -20,16 +21,16 @@ class ResponseParser(ABC):
def __init__(
self,
):
) -> None:
# Need pay attention to the item order in self.action_parsers
self.action_parsers = []
self.action_parsers: list[ActionParser] = []
@abstractmethod
def parse(self, response: str) -> Action:
def parse(self, response: Any) -> Action:
"""Parses the action from the response from the LLM.
Parameters:
- response (str): The response from the LLM.
- response: The response from the LLM, which can be a string or a dictionary.
Returns:
- action (Action): The action parsed from the response.
@@ -37,11 +38,11 @@ class ResponseParser(ABC):
pass
@abstractmethod
def parse_response(self, response) -> str:
def parse_response(self, response: Any) -> str:
"""Parses the action from the response from the LLM.
Parameters:
- response (str): The response from the LLM.
- response: The response from the LLM, which can be a string or a dictionary.
Returns:
- action_str (str): The action str parsed from the response.

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Type
@@ -106,11 +108,11 @@ class Agent(ABC):
self.llm.reset()
@property
def name(self):
def name(self) -> str:
return self.__class__.__name__
@classmethod
def register(cls, name: str, agent_cls: Type['Agent']):
def register(cls, name: str, agent_cls: Type['Agent']) -> None:
"""Registers an agent class in the registry.
Parameters:

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio
import copy
import os
@@ -190,7 +192,7 @@ class AgentController:
self.event_stream.add_event(system_message, EventSource.AGENT)
logger.debug(f'System message added to event stream: {system_message}')
async def close(self, set_stop_state=True) -> None:
async def close(self, set_stop_state: bool = True) -> None:
"""Closes the agent controller, canceling any ongoing tasks and unsubscribing from the event stream.
Note that it's fairly important that this closes properly, otherwise the state is incomplete.
@@ -242,18 +244,18 @@ class AgentController:
extra_merged = {'session_id': self.id, **extra}
getattr(logger, level)(message, extra=extra_merged, stacklevel=2)
def update_state_before_step(self):
def update_state_before_step(self) -> None:
self.state.iteration += 1
self.state.local_iteration += 1
async def update_state_after_step(self):
async def update_state_after_step(self) -> None:
# update metrics especially for cost. Use deepcopy to avoid it being modified by agent._reset()
self.state.local_metrics = copy.deepcopy(self.agent.llm.metrics)
async def _react_to_exception(
self,
e: Exception,
):
) -> None:
"""React to an exception by setting the agent state to error and sending a status message."""
# Store the error reason before setting the agent state
self.state.last_error = f'{type(e).__name__}: {str(e)}'
@@ -293,7 +295,10 @@ class AgentController:
# Set the agent state to ERROR after storing the reason
await self.set_agent_state_to(AgentState.ERROR)
async def _step_with_exception_handling(self):
def step(self) -> None:
asyncio.create_task(self._step_with_exception_handling())
async def _step_with_exception_handling(self) -> None:
try:
await self._step()
except Exception as e:
@@ -409,7 +414,7 @@ class AgentController:
should_step = self.should_step(event)
if should_step:
self.log(
'info',
'debug',
f'Stepping agent after event: {type(event).__name__}',
extra={'msg_type': 'STEPPING_AGENT'},
)
@@ -774,7 +779,7 @@ class AgentController:
return
self.log(
'info',
'debug',
f'LEVEL {self.state.delegate_level} LOCAL STEP {self.state.local_iteration} GLOBAL STEP {self.state.iteration}',
extra={'msg_type': 'STEP'},
)
@@ -963,7 +968,7 @@ class AgentController:
action_type = type(prev_action).__name__
elapsed_time = time.time() - timestamp
self.log(
'info',
'debug',
f'Cleared pending action after {elapsed_time:.2f}s: {action_type} (id={action_id})',
extra={'msg_type': 'PENDING_ACTION_CLEARED'},
)
@@ -972,7 +977,7 @@ class AgentController:
action_id = getattr(action, 'id', 'unknown')
action_type = type(action).__name__
self.log(
'info',
'debug',
f'Set pending action: {action_type} (id={action_id})',
extra={'msg_type': 'PENDING_ACTION_SET'},
)
@@ -1277,7 +1282,7 @@ class AgentController:
extra={'msg_type': 'METRICS'},
)
def __repr__(self):
def __repr__(self) -> str:
pending_action_info = '<none>'
if (
hasattr(self, '_pending_action_info')
@@ -1300,7 +1305,7 @@ class AgentController:
f'_pending_action={pending_action_info})'
)
def _is_awaiting_observation(self):
def _is_awaiting_observation(self) -> bool:
events = self.event_stream.get_events(reverse=True)
for event in events:
if isinstance(event, AgentStateChangedObservation):

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.action import Action
from openhands.events.action.message import MessageAction
@@ -79,7 +81,7 @@ class ReplayManager:
return event
@staticmethod
def get_replay_events(trajectory) -> list[Event]:
def get_replay_events(trajectory: list[dict]) -> list[Event]:
if not isinstance(trajectory, list):
raise ValueError(
f'Expected a list in {trajectory}, got {type(trajectory).__name__}'

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import os
import pickle
@@ -104,7 +106,9 @@ class State:
extra_data: dict[str, Any] = field(default_factory=dict)
last_error: str = ''
def save_to_session(self, sid: str, file_store: FileStore, user_id: str | None):
def save_to_session(
self, sid: str, file_store: FileStore, user_id: str | None
) -> None:
pickled = pickle.dumps(self)
logger.debug(f'Saving state to session {sid}:{self.agent_state}')
encoded = base64.b64encode(pickled).decode('utf-8')
@@ -165,7 +169,7 @@ class State:
state.agent_state = AgentState.LOADING
return state
def __getstate__(self):
def __getstate__(self) -> dict:
# don't pickle history, it will be restored from the event stream
state = self.__dict__.copy()
state['history'] = []
@@ -177,7 +181,7 @@ class State:
return state
def __setstate__(self, state):
def __setstate__(self, state: dict) -> None:
self.__dict__.update(state)
# make sure we always have the attribute history

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from openhands.core.exceptions import (
LLMMalformedActionError,
TaskInvalidStateError,
@@ -21,7 +23,7 @@ STATES = [
class Task:
id: str
goal: str
parent: 'Task | None'
parent: 'Task' | None
subtasks: list['Task']
def __init__(
@@ -29,8 +31,8 @@ class Task:
parent: 'Task',
goal: str,
state: str = OPEN_STATE,
subtasks=None, # noqa: B006
):
subtasks: list[dict | 'Task'] | None = None, # noqa: B006
) -> None:
"""Initializes a new instance of the Task class.
Args:
@@ -53,15 +55,15 @@ class Task:
if isinstance(subtask, Task):
self.subtasks.append(subtask)
else:
goal = subtask.get('goal')
state = subtask.get('state')
goal = str(subtask.get('goal', ''))
state = str(subtask.get('state', OPEN_STATE))
subtasks = subtask.get('subtasks')
logger.debug(f'Reading: {goal}, {state}, {subtasks}')
self.subtasks.append(Task(self, goal, state, subtasks))
self.state = OPEN_STATE
def to_string(self, indent=''):
def to_string(self, indent: str = '') -> str:
"""Returns a string representation of the task and its subtasks.
Args:
@@ -86,7 +88,7 @@ class Task:
result += subtask.to_string(indent + ' ')
return result
def to_dict(self):
def to_dict(self) -> dict:
"""Returns a dictionary representation of the task.
Returns:
@@ -99,10 +101,11 @@ class Task:
'subtasks': [t.to_dict() for t in self.subtasks],
}
def set_state(self, state):
def set_state(self, state: str) -> None:
"""Sets the state of the task and its subtasks.
Args: state: The new state of the task.
Args:
state: The new state of the task.
Raises:
TaskInvalidStateError: If the provided state is invalid.
@@ -123,7 +126,7 @@ class Task:
if self.parent is not None:
self.parent.set_state(state)
def get_current_task(self) -> 'Task | None':
def get_current_task(self) -> 'Task' | None:
"""Retrieves the current task in progress.
Returns:
@@ -155,11 +158,11 @@ class RootTask(Task):
goal: str = ''
parent: None = None
def __init__(self):
def __init__(self) -> None:
self.subtasks = []
self.state = OPEN_STATE
def __str__(self):
def __str__(self) -> str:
"""Returns a string representation of the root_task.
Returns:
@@ -194,7 +197,12 @@ class RootTask(Task):
task = task.subtasks[part]
return task
def add_subtask(self, parent_id: str, goal: str, subtasks: list | None = None):
def add_subtask(
self,
parent_id: str,
goal: str,
subtasks: list[dict | Task] | None = None,
) -> None:
"""Adds a subtask to a parent task.
Args:
@@ -207,7 +215,7 @@ class RootTask(Task):
child = Task(parent=parent, goal=goal, subtasks=subtasks)
parent.subtasks.append(child)
def set_subtask_state(self, id: str, state: str):
def set_subtask_state(self, id: str, state: str) -> None:
"""Sets the state of a subtask.
Args:

View File

@@ -25,7 +25,7 @@ class StuckDetector:
def __init__(self, state: State):
self.state = state
def is_stuck(self, headless_mode: bool = True):
def is_stuck(self, headless_mode: bool = True) -> bool:
"""Checks if the agent is stuck in a loop.
Args:
@@ -109,7 +109,9 @@ class StuckDetector:
return False
def _is_stuck_repeating_action_observation(self, last_actions, last_observations):
def _is_stuck_repeating_action_observation(
self, last_actions: list[Event], last_observations: list[Event]
) -> bool:
# scenario 1: same action, same observation
# it takes 4 actions and 4 observations to detect a loop
# assert len(last_actions) == 4 and len(last_observations) == 4
@@ -130,7 +132,9 @@ class StuckDetector:
return False
def _is_stuck_repeating_action_error(self, last_actions, last_observations):
def _is_stuck_repeating_action_error(
self, last_actions: list[Event], last_observations: list[Event]
) -> bool:
# scenario 2: same action, errors
# it takes 3 actions and 3 observations to detect a loop
# check if the last three actions are the same and result in errors
@@ -155,7 +159,12 @@ class StuckDetector:
'SyntaxError: unterminated string literal (detected at line'
):
if self._check_for_consistent_line_error(
last_observations[:3], error_message
[
obs
for obs in last_observations[:3]
if isinstance(obs, IPythonRunCellObservation)
],
error_message,
):
logger.warning(warning)
return True
@@ -163,13 +172,20 @@ class StuckDetector:
'SyntaxError: invalid syntax. Perhaps you forgot a comma?',
'SyntaxError: incomplete input',
) and self._check_for_consistent_invalid_syntax(
last_observations[:3], error_message
[
obs
for obs in last_observations[:3]
if isinstance(obs, IPythonRunCellObservation)
],
error_message,
):
logger.warning(warning)
return True
return False
def _check_for_consistent_invalid_syntax(self, observations, error_message):
def _check_for_consistent_invalid_syntax(
self, observations: list[IPythonRunCellObservation], error_message: str
) -> bool:
first_lines = []
valid_observations = []
@@ -210,7 +226,9 @@ class StuckDetector:
== 1
)
def _check_for_consistent_line_error(self, observations, error_message):
def _check_for_consistent_line_error(
self, observations: list[IPythonRunCellObservation], error_message: str
) -> bool:
error_lines = []
for obs in observations:
@@ -237,7 +255,7 @@ class StuckDetector:
# and the 3rd-to-last line is identical across all occurrences
return len(error_lines) == 3 and len(set(error_lines)) == 1
def _is_stuck_monologue(self, filtered_history):
def _is_stuck_monologue(self, filtered_history: list[Event]) -> bool:
# scenario 3: monologue
# check for repeated MessageActions with source=AGENT
# see if the agent is engaged in a good old monologue, telling itself the same thing over and over
@@ -271,7 +289,9 @@ class StuckDetector:
return True
return False
def _is_stuck_action_observation_pattern(self, filtered_history):
def _is_stuck_action_observation_pattern(
self, filtered_history: list[Event]
) -> bool:
# scenario 4: action, observation pattern on the last six steps
# check if the agent repeats the same (Action, Observation)
# every other step in the last six steps
@@ -313,7 +333,7 @@ class StuckDetector:
return True
return False
def _is_stuck_context_window_error(self, filtered_history):
def _is_stuck_context_window_error(self, filtered_history: list[Event]) -> bool:
"""Detects if we're stuck in a loop of context window errors.
This happens when we repeatedly get context window errors and try to trim,
@@ -361,7 +381,7 @@ class StuckDetector:
return False
def _eq_no_pid(self, obj1, obj2):
def _eq_no_pid(self, obj1: Event, obj2: Event) -> bool:
if isinstance(obj1, IPythonRunCellAction) and isinstance(
obj2, IPythonRunCellAction
):

View File

@@ -135,7 +135,6 @@ def event_to_dict(event: 'Event') -> dict:
k: (v.value if isinstance(v, Enum) else _convert_pydantic_to_dict(v))
for k, v in props.items()
}
logger.debug(f'extras data in event_to_dict: {d["extras"]}')
# Include success field for CmdOutputObservation
if hasattr(event, 'success'):
d['success'] = event.success

View File

@@ -46,7 +46,7 @@ class GitHubService(BaseGitService, GitService):
@property
def provider(self) -> str:
return ProviderType.GITHUB.value
async def _get_github_headers(self) -> dict:
"""Retrieve the GH Token from settings store to construct the headers."""
if not self.token:
@@ -351,6 +351,7 @@ class GitHubService(BaseGitService, GitService):
if task_type != TaskType.OPEN_PR:
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITHUB,
task_type=task_type,
repo=repo_name,
issue_number=pr['number'],
@@ -363,6 +364,7 @@ class GitHubService(BaseGitService, GitService):
repo_name = issue['repository']['nameWithOwner']
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITHUB,
task_type=TaskType.OPEN_ISSUE,
repo=repo_name,
issue_number=issue['number'],

View File

@@ -16,6 +16,6 @@ A comment on the issue has been addressed to you.
When you're done, make sure to
1. Use the `GITHUB_TOKEN` environment variable and GitHub API to open a new PR
2. Name the branch using the format: `openhands/{branch-name}`
2. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
3. The PR description should mention that it "fixes" or "closes" the issue number
4. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`

View File

@@ -10,6 +10,8 @@ from openhands.integrations.service_types import (
ProviderType,
Repository,
RequestMethod,
SuggestedTask,
TaskType,
UnknownException,
User,
)
@@ -22,7 +24,6 @@ class GitLabService(BaseGitService, GitService):
GRAPHQL_URL = 'https://gitlab.com/api/graphql'
token: SecretStr = SecretStr('')
refresh = False
def __init__(
self,
@@ -46,7 +47,7 @@ class GitLabService(BaseGitService, GitService):
@property
def provider(self) -> str:
return ProviderType.GITLAB.value
async def _get_gitlab_headers(self) -> dict[str, Any]:
"""
Retrieve the GitLab Token to construct the headers
@@ -107,7 +108,7 @@ class GitLabService(BaseGitService, GitService):
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def execute_graphql_query(self, query: str, variables: dict[str, Any]) -> Any:
async def execute_graphql_query(self, query: str, variables: dict[str, Any] = {}) -> Any:
"""
Execute a GraphQL query against the GitLab GraphQL API
@@ -245,6 +246,138 @@ class GitLabService(BaseGitService, GitService):
for repo in all_repos
]
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories.
Returns:
- Merge requests authored by the user.
- Issues assigned to the user.
"""
# Get user info to use in queries
user = await self.get_user()
username = user.login
# GraphQL query to get merge requests
query = """
query GetUserTasks {
currentUser {
authoredMergeRequests(state: opened, sort: UPDATED_DESC, first: 100) {
nodes {
id
iid
title
project {
fullPath
}
conflicts
mergeStatus
pipelines(first: 1) {
nodes {
status
}
}
discussions(first: 100) {
nodes {
notes {
nodes {
resolvable
resolved
}
}
}
}
}
}
}
}
"""
try:
tasks: list[SuggestedTask] = []
# Get merge requests using GraphQL
response = await self.execute_graphql_query(query)
data = response.get('currentUser', {})
# Process merge requests
merge_requests = data.get('authoredMergeRequests', {}).get('nodes', [])
for mr in merge_requests:
repo_name = mr.get('project', {}).get('fullPath', '')
mr_number = mr.get('iid')
title = mr.get('title', '')
# Start with default task type
task_type = TaskType.OPEN_PR
# Check for specific states
if mr.get('conflicts'):
task_type = TaskType.MERGE_CONFLICTS
elif (
mr.get('pipelines', {}).get('nodes', [])
and mr.get('pipelines', {}).get('nodes', [])[0].get('status')
== 'FAILED'
):
task_type = TaskType.FAILING_CHECKS
else:
# Check for unresolved comments
has_unresolved_comments = False
for discussion in mr.get('discussions', {}).get('nodes', []):
for note in discussion.get('notes', {}).get('nodes', []):
if note.get('resolvable') and not note.get('resolved'):
has_unresolved_comments = True
break
if has_unresolved_comments:
break
if has_unresolved_comments:
task_type = TaskType.UNRESOLVED_COMMENTS
# Only add the task if it's not OPEN_PR
if task_type != TaskType.OPEN_PR:
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITLAB,
task_type=task_type,
repo=repo_name,
issue_number=mr_number,
title=title,
)
)
# Get assigned issues using REST API
url = f"{self.BASE_URL}/issues"
params = {
"assignee_username": username,
"state": "opened",
"scope": "assigned_to_me"
}
issues_response, _ = await self._make_request(
method=RequestMethod.GET,
url=url,
params=params
)
# Process issues
for issue in issues_response:
repo_name = issue.get('references', {}).get('full', '').split('#')[0].strip()
issue_number = issue.get('iid')
title = issue.get('title', '')
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITLAB,
task_type=TaskType.OPEN_ISSUE,
repo=repo_name,
issue_number=issue_number,
title=title,
)
)
return tasks
except Exception:
return []
gitlab_service_cls = os.environ.get(
'OPENHANDS_GITLAB_SERVICE_CLS',

View File

@@ -25,6 +25,7 @@ from openhands.integrations.service_types import (
GitService,
ProviderType,
Repository,
SuggestedTask,
User,
)
from openhands.server.types import AppMode
@@ -66,6 +67,10 @@ class SecretStore(BaseModel):
default_factory=lambda: MappingProxyType({})
)
custom_secrets: CUSTOM_SECRETS_TYPE = Field(
default_factory=lambda: MappingProxyType({})
)
model_config = {
'frozen': True,
'validate_assignment': True,
@@ -97,16 +102,32 @@ class SecretStore(BaseModel):
return tokens
@field_serializer('custom_secrets')
def custom_secrets_serializer(
self, custom_secrets: CUSTOM_SECRETS_TYPE, info: SerializationInfo
):
secrets = {}
expose_secrets = info.context and info.context.get('expose_secrets', False)
if custom_secrets:
for secret_name, secret_key in custom_secrets.items():
secrets[secret_name] = (
secret_key.get_secret_value()
if expose_secrets
else pydantic_encoder(secret_key)
)
return secrets
@model_validator(mode='before')
@classmethod
def convert_dict_to_mappingproxy(
cls, data: dict[str, dict[str, dict[str, str]]] | PROVIDER_TOKEN_TYPE
) -> dict[str, MappingProxyType[Any, Any]]:
cls, data: dict[str, dict[str, Any] | MappingProxyType] | PROVIDER_TOKEN_TYPE
) -> dict[str, MappingProxyType | None]:
"""Custom deserializer to convert dictionary into MappingProxyType"""
if not isinstance(data, dict):
raise ValueError('SecretStore must be initialized with a dictionary')
new_data: dict[str, MappingProxyType[Any, Any]] = {}
new_data: dict[str, MappingProxyType | None] = {}
if 'provider_tokens' in data:
tokens = data['provider_tokens']
@@ -128,6 +149,22 @@ class SecretStore(BaseModel):
# Convert to MappingProxyType
new_data['provider_tokens'] = MappingProxyType(converted_tokens)
elif isinstance(tokens, MappingProxyType):
new_data['provider_tokens'] = tokens
if 'custom_secrets' in data:
secrets = data['custom_secrets']
if isinstance(secrets, dict):
converted_secrets = {}
for key, value in secrets.items():
if isinstance(value, str):
converted_secrets[key] = SecretStr(value)
elif isinstance(value, SecretStr):
converted_secrets[key] = value
new_data['custom_secrets'] = MappingProxyType(converted_secrets)
elif isinstance(secrets, MappingProxyType):
new_data['custom_secrets'] = secrets
return new_data
@@ -191,7 +228,7 @@ class ProviderHandler:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""
Get repositories from a selected providers with pagination support
Get repositories from providers
"""
all_repos: list[Repository] = []
@@ -205,6 +242,21 @@ class ProviderHandler:
return all_repos
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""
Get suggested tasks from providers
"""
tasks: list[SuggestedTask] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service_repos = await service.get_suggested_tasks()
tasks.extend(service_repos)
except Exception as e:
logger.warning(f'Error fetching repos from {provider}: {e}')
return tasks
async def search_repositories(
self,
query: str,

View File

@@ -23,6 +23,7 @@ class TaskType(str, Enum):
class SuggestedTask(BaseModel):
git_provider: ProviderType
task_type: TaskType
repo: str
issue_number: int
@@ -110,7 +111,7 @@ class BaseGitService(ABC):
return UnknownException('Unknown error')
def handle_http_error(self, e: HTTPError) -> UnknownException:
logger.warning(f'HTTP error on {self.provider} API: {e}')
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
return UnknownException('Unknown error')
@@ -149,3 +150,7 @@ class GitService(Protocol):
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""Get repositories for the authenticated user"""
...
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories"""
...

View File

@@ -713,7 +713,7 @@ class LLM(RetryMixin, DebugMixin):
completion_response=response, **extra_kwargs
)
except Exception as e:
logger.error(f'Error getting cost from litellm: {e}')
logger.debug(f'Error getting cost from litellm: {e}')
if cost is None:
_model_name = '/'.join(self.config.model.split('/')[1:])

View File

@@ -16,7 +16,6 @@ from openhands.resolver.interfaces.gitlab import GitlabIssueHandler
from openhands.resolver.interfaces.issue import Issue
from openhands.resolver.interfaces.issue_definitions import ServiceContextIssue
from openhands.resolver.io_utils import (
load_all_resolver_outputs,
load_single_resolver_output,
)
from openhands.resolver.patching import apply_diff, parse_patch
@@ -549,40 +548,6 @@ def process_single_issue(
)
def process_all_successful_issues(
output_dir: str,
token: str,
username: str,
platform: ProviderType,
pr_type: str,
llm_config: LLMConfig,
fork_owner: str | None,
base_domain: str | None = None,
) -> None:
# Determine default base_domain based on platform
if base_domain is None:
base_domain = 'github.com' if platform == ProviderType.GITHUB else 'gitlab.com'
output_path = os.path.join(output_dir, 'output.jsonl')
for resolver_output in load_all_resolver_outputs(output_path):
if resolver_output.success:
logger.info(f'Processing issue {resolver_output.issue.number}')
process_single_issue(
output_dir,
resolver_output,
token,
username,
platform,
pr_type,
llm_config,
fork_owner,
False,
None,
None,
None,
base_domain,
)
def main() -> None:
parser = argparse.ArgumentParser(
description='Send a pull request to Github or Gitlab.'
@@ -703,42 +668,28 @@ def main() -> None:
if not os.path.exists(my_args.output_dir):
raise ValueError(f'Output directory {my_args.output_dir} does not exist.')
if my_args.issue_number == 'all_successful':
if not username:
raise ValueError('username is required.')
process_all_successful_issues(
my_args.output_dir,
token,
username,
platform,
my_args.pr_type,
llm_config,
my_args.fork_owner,
my_args.base_domain,
)
else:
if not my_args.issue_number.isdigit():
raise ValueError(f'Issue number {my_args.issue_number} is not a number.')
issue_number = int(my_args.issue_number)
output_path = os.path.join(my_args.output_dir, 'output.jsonl')
resolver_output = load_single_resolver_output(output_path, issue_number)
if not username:
raise ValueError('username is required.')
process_single_issue(
my_args.output_dir,
resolver_output,
token,
username,
platform,
my_args.pr_type,
llm_config,
my_args.fork_owner,
my_args.send_on_failure,
my_args.target_branch,
my_args.reviewer,
my_args.pr_title,
my_args.base_domain,
)
if not my_args.issue_number.isdigit():
raise ValueError(f'Issue number {my_args.issue_number} is not a number.')
issue_number = int(my_args.issue_number)
output_path = os.path.join(my_args.output_dir, 'output.jsonl')
resolver_output = load_single_resolver_output(output_path, issue_number)
if not username:
raise ValueError('username is required.')
process_single_issue(
my_args.output_dir,
resolver_output,
token,
username,
platform,
my_args.pr_type,
llm_config,
my_args.fork_owner,
my_args.send_on_failure,
my_args.target_branch,
my_args.reviewer,
my_args.pr_title,
my_args.base_domain,
)
if __name__ == '__main__':

View File

@@ -555,7 +555,7 @@ if __name__ == '__main__':
# Start the file viewer server in a separate thread
logger.info('Starting file viewer server')
_file_viewer_port = find_available_tcp_port(
min_port=args.port + 1, max_port=args.port + 10000
min_port=args.port + 1, max_port=min(args.port + 1024, 65535)
)
server_url, _ = start_file_viewer_server(port=_file_viewer_port)
logger.info(f'File viewer server started at {server_url}')

View File

@@ -331,9 +331,9 @@ class ActionExecutionClient(Runtime):
if self.mcp_clients is None:
self.log(
'debug',
f'Creating MCP clients with servers: {self.config.mcp.sse.mcp_servers}',
f'Creating MCP clients with servers: {self.config.mcp.mcp_servers}',
)
self.mcp_clients = await create_mcp_clients(self.config.mcp.sse.mcp_servers)
self.mcp_clients = await create_mcp_clients(self.config.mcp.mcp_servers)
return await call_tool_mcp_handler(self.mcp_clients, action)
async def aclose(self) -> None:

View File

@@ -68,7 +68,10 @@ def check_dependencies(code_repo_path: str, poetry_venvs_path: str):
import libtmux
server = libtmux.Server()
session = server.new_session(session_name='test-session')
try:
session = server.new_session(session_name='test-session')
except Exception:
raise ValueError('tmux is not properly installed or available on the path.')
pane = session.attached_pane
pane.send_keys('echo "test"')
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)

View File

@@ -7,6 +7,7 @@ import re
from uuid import uuid4
import tornado
import tornado.websocket
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
from tornado.escape import json_decode, json_encode, url_escape
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
@@ -139,7 +140,7 @@ class JupyterKernel:
wait=wait_fixed(2),
)
async def execute(self, code: str, timeout: int = 120) -> str:
if not self.ws:
if not self.ws or self.ws.stream.closed():
await self._connect()
msg_id = uuid4().hex

View File

@@ -215,13 +215,13 @@ class BashSession:
self.session.set_option('history-limit', str(self.HISTORY_LIMIT), _global=True)
self.session.history_limit = self.HISTORY_LIMIT
# We need to create a new pane because the initial pane's history limit is (default) 2000
_initial_window = self.session.attached_window
_initial_window = self.session.active_window
self.window = self.session.new_window(
window_name='bash',
window_shell=window_command,
start_directory=self.work_dir, # This parameter is supported by libtmux
)
self.pane = self.window.attached_pane
self.pane = self.window.active_pane
logger.debug(f'pane: {self.pane}; history_limit: {self.session.history_limit}')
_initial_window.kill_window()

View File

@@ -82,11 +82,20 @@ class GitHandler:
Returns:
str | None: A valid Git reference or None if no valid reference is found.
"""
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{self._get_current_branch()})")'
ref_default_branch = 'origin/' + self._get_current_branch()
current_branch = self._get_current_branch()
default_branch = self._get_default_branch()
ref_current_branch = f'origin/{current_branch}'
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = 'origin/' + default_branch
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
refs = [ref_non_default_branch, ref_default_branch, ref_new_repo]
refs = [
ref_current_branch,
ref_non_default_branch,
ref_default_branch,
ref_new_repo,
]
for ref in refs:
if self._verify_ref_exists(ref):
return ref
@@ -111,7 +120,7 @@ class GitHandler:
output = self.execute(cmd, self.cwd)
return output.content if output.exit_code == 0 else ''
def _get_current_branch(self) -> str:
def _get_default_branch(self) -> str:
"""
Retrieves the primary Git branch name of the repository.
@@ -122,6 +131,17 @@ class GitHandler:
output = self.execute(cmd, self.cwd)
return output.content.split()[-1].strip()
def _get_current_branch(self) -> str:
"""
Retrieves the currently selected Git branch.
Returns:
str: The name of the current branch.
"""
cmd = 'git rev-parse --abbrev-ref HEAD'
output = self.execute(cmd, self.cwd)
return output.content.strip()
def _get_changed_files(self) -> list[str]:
"""
Retrieves a list of changed files compared to a valid Git reference.

View File

@@ -1,34 +0,0 @@
from fastapi import Request
from pydantic import SecretStr
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
def get_provider_tokens(request: Request) -> PROVIDER_TOKEN_TYPE | None:
"""Get GitHub token from request state. For backward compatibility."""
return getattr(request.state, 'provider_tokens', None)
def get_access_token(request: Request) -> SecretStr | None:
return getattr(request.state, 'access_token', None)
def get_user_id(request: Request) -> str | None:
return getattr(request.state, 'user_id', None)
def get_github_token(request: Request) -> SecretStr | None:
provider_tokens = get_provider_tokens(request)
if provider_tokens and ProviderType.GITHUB in provider_tokens:
return provider_tokens[ProviderType.GITHUB].token
return None
def get_github_user_id(request: Request) -> str | None:
provider_tokens = get_provider_tokens(request)
if provider_tokens and ProviderType.GITHUB in provider_tokens:
return provider_tokens[ProviderType.GITHUB].user_id
return None

View File

@@ -20,6 +20,7 @@ class ServerConfig(ServerConfigInterface):
)
conversation_manager_class: str = 'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager'
monitoring_listener_class: str = 'openhands.server.monitoring.MonitoringListener'
user_auth_class: str = 'openhands.server.user_auth.default_user_auth.DefaultUserAuth'
def verify_config(self):
if self.config_cls:

View File

@@ -53,7 +53,6 @@ class ConversationManager(ABC):
connection_id: str,
settings: Settings,
user_id: str | None,
github_user_id: str | None,
) -> EventStore | None:
"""Join a conversation and return its event stream."""
@@ -82,7 +81,6 @@ class ConversationManager(ABC):
user_id: str | None,
initial_user_msg: MessageAction | None = None,
replay_json: str | None = None,
github_user_id: str | None = None,
) -> EventStore:
"""Start an event loop if one is not already running"""

Some files were not shown because too many files have changed in this diff Show More