mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
52 Commits
openhands-
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5075623774 | ||
|
|
ee5f49afc1 | ||
|
|
7fe692a7bd | ||
|
|
21948fa81b | ||
|
|
d646b2089d | ||
|
|
f54d953fe1 | ||
|
|
4e7af78b39 | ||
|
|
252c70984c | ||
|
|
5ea096e95b | ||
|
|
a01fb9dca3 | ||
|
|
51af29208f | ||
|
|
e77f435901 | ||
|
|
5fb0eec61e | ||
|
|
4af84a29dc | ||
|
|
7a0488c012 | ||
|
|
581d5ec7a8 | ||
|
|
cfbe77b367 | ||
|
|
3236602919 | ||
|
|
aa2f34a1f5 | ||
|
|
73c38f1163 | ||
|
|
0dd919bacf | ||
|
|
5ad361623d | ||
|
|
c333938384 | ||
|
|
ebf3bf606a | ||
|
|
c2293ad1dd | ||
|
|
6f7d054385 | ||
|
|
e9cafb0372 | ||
|
|
13097f9d1d | ||
|
|
2a66439ca6 | ||
|
|
3876f4a59c | ||
|
|
3db118f3d9 | ||
|
|
fe1bb1c233 | ||
|
|
154ef7391a | ||
|
|
5498ca1f8b | ||
|
|
2cc6a51fe8 | ||
|
|
409d132747 | ||
|
|
2c47a1b33f | ||
|
|
8983eb4cc1 | ||
|
|
bd3e38fe67 | ||
|
|
8488dd2a03 | ||
|
|
d16842f413 | ||
|
|
9cdb8d06c0 | ||
|
|
3297e4d5a8 | ||
|
|
f9d052c493 | ||
|
|
dc3e43b999 | ||
|
|
8bd2205258 | ||
|
|
6ae84bf992 | ||
|
|
afea9f4bec | ||
|
|
8b1a7dff7e | ||
|
|
5e3123964f | ||
|
|
1ffd66f62e | ||
|
|
b04ec03062 |
129
.github/workflows/openhands-resolver.yml
vendored
129
.github/workflows/openhands-resolver.yml
vendored
@@ -116,7 +116,7 @@ jobs:
|
||||
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
required_vars=("LLM_MODEL" "LLM_API_KEY")
|
||||
required_vars=("LLM_API_KEY")
|
||||
for var in "${required_vars[@]}"; do
|
||||
if [ -z "${!var}" ]; then
|
||||
echo "Error: Required environment variable $var is not set."
|
||||
@@ -125,14 +125,14 @@ jobs:
|
||||
done
|
||||
|
||||
# Check optional variables and warn about fallbacks
|
||||
if [ -z "$PAT_TOKEN" ]; then
|
||||
echo "Warning: PAT_TOKEN is not set, falling back to GITHUB_TOKEN"
|
||||
fi
|
||||
|
||||
if [ -z "$LLM_BASE_URL" ]; then
|
||||
echo "Warning: LLM_BASE_URL is not set, will use default API endpoint"
|
||||
fi
|
||||
|
||||
if [ -z "$PAT_TOKEN" ]; then
|
||||
echo "Warning: PAT_TOKEN is not set, falling back to GITHUB_TOKEN"
|
||||
fi
|
||||
|
||||
if [ -z "$PAT_USERNAME" ]; then
|
||||
echo "Warning: PAT_USERNAME is not set, will use openhands-agent"
|
||||
fi
|
||||
@@ -268,30 +268,58 @@ jobs:
|
||||
grep "branch created" branch_result.txt | sed 's/.*\///g; s/.expand=1//g' > branch_name.txt
|
||||
fi
|
||||
|
||||
- name: Comment on issue
|
||||
# Step leaves comment for when agent is invoked on PR
|
||||
- name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
|
||||
uses: actions/github-script@v7
|
||||
if: always() # Comment on issue even if the previous steps fail
|
||||
if: always()
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issueNumber = ${{ env.ISSUE_NUMBER }};
|
||||
let logContent = '';
|
||||
|
||||
try {
|
||||
logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim();
|
||||
} catch (error) {
|
||||
console.error('Error reading pr_result.txt file:', error);
|
||||
}
|
||||
|
||||
const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`;
|
||||
|
||||
// Check logs from send_pull_request.py (pushes code to GitHub)
|
||||
if (logContent.includes("Updated pull request")) {
|
||||
console.log("Updated pull request found. Skipping comment.");
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
} else if (logContent.includes(noChangesMessage)) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.`
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
}
|
||||
|
||||
# Step leaves comment for when agent is invoked on issue
|
||||
- name: Comment on issue # Comment link to either PR or branch created by agent
|
||||
uses: actions/github-script@v7
|
||||
if: always() # Comment on issue even if the previous steps fail
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const issueNumber = ${{ env.ISSUE_NUMBER }};
|
||||
const success = ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }};
|
||||
|
||||
let prNumber = '';
|
||||
let branchName = '';
|
||||
let logContent = '';
|
||||
const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`;
|
||||
|
||||
try {
|
||||
if (success){
|
||||
logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim();
|
||||
} else {
|
||||
logContent = fs.readFileSync('/tmp/branch_result.txt', 'utf8').trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading results file:', error);
|
||||
}
|
||||
let resultExplanation = '';
|
||||
|
||||
try {
|
||||
if (success) {
|
||||
@@ -303,32 +331,63 @@ jobs:
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
|
||||
if (logContent.includes(noChangesMessage)) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.`
|
||||
});
|
||||
} else if (success && prNumber) {
|
||||
|
||||
try {
|
||||
if (!success){
|
||||
// Read result_explanation from JSON file for failed resolution
|
||||
const outputFilePath = path.resolve('/tmp/output/output.jsonl');
|
||||
if (fs.existsSync(outputFilePath)) {
|
||||
const outputContent = fs.readFileSync(outputFilePath, 'utf8');
|
||||
const jsonLines = outputContent.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
if (jsonLines.length > 0) {
|
||||
// First entry in JSON lines has the key 'result_explanation'
|
||||
const firstEntry = JSON.parse(jsonLines[0]);
|
||||
resultExplanation = firstEntry.result_explanation || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error){
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
|
||||
// Check "success" log from resolver output
|
||||
if (success && prNumber) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `A potential fix has been generated and a draft PR #${prNumber} has been created. Please review the changes.`
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
} else if (!success && branchName) {
|
||||
let commentBody = `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.`;
|
||||
|
||||
if (resultExplanation) {
|
||||
commentBody += `\n\nAdditional details about the failure:\n${resultExplanation}`;
|
||||
}
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.`
|
||||
});
|
||||
} else {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Please check the [workflow logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for more information.`
|
||||
body: commentBody
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
}
|
||||
|
||||
# Leave error comment when both PR/Issue comment handling fail
|
||||
- name: Fallback Error Comment
|
||||
uses: actions/github-script@v7
|
||||
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
script: |
|
||||
const issueNumber = ${{ env.ISSUE_NUMBER }};
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Please check the [workflow logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for more information.`
|
||||
});
|
||||
|
||||
@@ -100,7 +100,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.15-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.16-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
14
README.md
14
README.md
@@ -43,17 +43,17 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
|
||||
system requirements and more information.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/home/openhands/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.15
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.16
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
@@ -71,6 +71,14 @@ or run it on tagged issues with [a github action](https://github.com/All-Hands-A
|
||||
|
||||
Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
|
||||
|
||||
> [!CAUTION]
|
||||
> OpenHands is meant to be run by a single user on their local workstation.
|
||||
> It is not appropriate for multi-tenant deployments, where multiple users share the same instance--there is no built-in isolation or scalability.
|
||||
>
|
||||
> If you're interested in running OpenHands in a multi-tenant environment, please
|
||||
> [get in touch with us](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
|
||||
> for advanced deployment options.
|
||||
|
||||
If you want to modify the OpenHands source code, check out [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
|
||||
|
||||
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/modules/usage/troubleshooting) can help.
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.15-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.16-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -154,6 +154,10 @@ model = "gpt-4o"
|
||||
# Drop any unmapped (unsupported) params without causing an exception
|
||||
#drop_params = false
|
||||
|
||||
# Modify params for litellm to do transformations like adding a default message, when a message is empty.
|
||||
# Note: this setting is global, unlike drop_params, it cannot be overridden in each call to litellm.
|
||||
#modify_params = true
|
||||
|
||||
# Using the prompt caching feature if provided by the LLM and supported
|
||||
#caching_prompt = true
|
||||
|
||||
|
||||
@@ -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.15-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.16-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
|
||||
|
||||
# Kubernetes
|
||||
|
||||
Il existe différentes façons d'exécuter OpenHands sur Kubernetes ou OpenShift. Ce guide présente une façon possible :
|
||||
1. Créer un PV "en tant qu'administrateur du cluster" pour mapper les données workspace_base et le répertoire docker au pod via le nœud worker
|
||||
2. Créer un PVC pour pouvoir monter ces PV sur le pod
|
||||
3. Créer un pod qui contient deux conteneurs : les conteneurs OpenHands et Sandbox
|
||||
|
||||
## Étapes détaillées pour l'exemple ci-dessus
|
||||
|
||||
> Remarque : Assurez-vous d'être connecté au cluster avec le compte approprié pour chaque étape. La création de PV nécessite un administrateur de cluster !
|
||||
|
||||
> Assurez-vous d'avoir les autorisations de lecture/écriture sur le hostPath utilisé ci-dessous (c'est-à-dire /tmp/workspace)
|
||||
|
||||
1. Créer le PV :
|
||||
Le fichier yaml d'exemple ci-dessous peut être utilisé par un administrateur de cluster pour créer le PV.
|
||||
- workspace-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: workspace-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /tmp/workspace
|
||||
```
|
||||
|
||||
```bash
|
||||
# appliquer le fichier yaml
|
||||
$ oc create -f workspace-pv.yaml
|
||||
persistentvolume/workspace-pv created
|
||||
|
||||
# vérifier :
|
||||
$ oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
- docker-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: docker-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
```
|
||||
|
||||
```bash
|
||||
# appliquer le fichier yaml
|
||||
$ oc create -f docker-pv.yaml
|
||||
persistentvolume/docker-pv created
|
||||
|
||||
# vérifier :
|
||||
oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
docker-pv 2Gi RWO Retain Available 6m55s
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
2. Créer le PVC :
|
||||
Exemple de fichier yaml PVC ci-dessous :
|
||||
|
||||
- workspace-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: workspace-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# créer le pvc
|
||||
$ oc create -f workspace-pvc.yaml
|
||||
persistentvolumeclaim/workspace-pvc created
|
||||
|
||||
# vérifier
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
workspace-pvc Pending hcloud-volumes 4s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
8s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
- docker-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: docker-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# créer le pvc
|
||||
$ oc create -f docker-pvc.yaml
|
||||
persistentvolumeclaim/docker-pvc created
|
||||
|
||||
# vérifier
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Pending hcloud-volumes 4s
|
||||
workspace-pvc Pending hcloud-volumes 2m53s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
3. Créer le fichier yaml du pod :
|
||||
Exemple de fichier yaml de pod ci-dessous :
|
||||
|
||||
- pod.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: ghcr.io/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: WORKSPACE_MOUNT_PATH
|
||||
value: "/opt/workspace_base"
|
||||
volumeMounts:
|
||||
- name: workspace-volume
|
||||
mountPath: /opt/workspace_base
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- name: openhands-sandbox-2024
|
||||
image: ghcr.io/all-hands-ai/sandbox:main
|
||||
ports:
|
||||
- containerPort: 51963
|
||||
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
|
||||
volumes:
|
||||
- name: workspace-volume
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-pvc
|
||||
- name: docker-sock
|
||||
persistentVolumeClaim:
|
||||
claimName: docker-pvc
|
||||
```
|
||||
|
||||
|
||||
```bash
|
||||
# créer le pod
|
||||
$ oc create -f pod.yaml
|
||||
W0716 11:22:07.776271 107626 warnings.go:70] would violate PodSecurity "restricted:v1.24": allowPrivilegeEscalation != false (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.runAsNonRoot=true), seccompProfile (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
|
||||
pod/openhands-app-2024 created
|
||||
|
||||
# L'avertissement ci-dessus peut être ignoré pour l'instant car nous ne modifierons pas les restrictions SCC.
|
||||
|
||||
# vérifier
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 Pending 0 5s
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 ContainerCreating 0 15s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
38s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
23s Normal ExternalProvisioning persistentvolumeclaim/docker-pvc waiting for a volume to be created, either by external provisioner "csi.hetzner.cloud" or manually created by system administrator
|
||||
27s Normal Provisioning persistentvolumeclaim/docker-pvc External provisioner is provisioning volume for claim "openhands/docker-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/docker-pvc Successfully provisioned volume pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252
|
||||
16s Normal Scheduled pod/openhands-app-2024 Successfully assigned All-Hands-AI/OpenHands-app-2024 to worker1.hub.internal.blakane.com
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252"
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-31f15b25-faad-4665-a25f-201a530379af"
|
||||
6s Normal AddedInterface pod/openhands-app-2024 Add eth0 [10.128.2.48/23] from openshift-sdn
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/openhands:main" already present on machine
|
||||
6s Normal Created pod/openhands-app-2024 Created container openhands-app-2024
|
||||
6s Normal Started pod/openhands-app-2024 Started container openhands-app-2024
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/sandbox:main" already present on machine
|
||||
5s Normal Created pod/openhands-app-2024 Created container openhands-sandbox-2024
|
||||
5s Normal Started pod/openhands-app-2024 Started container openhands-sandbox-2024
|
||||
83s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
27s Normal Provisioning persistentvolumeclaim/workspace-pvc External provisioner is provisioning volume for claim "openhands/workspace-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/workspace-pvc Successfully provisioned volume pvc-31f15b25-faad-4665-a25f-201a530379af
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 2/2 Running 0 23s
|
||||
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Bound pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252 10Gi RWO hcloud-volumes 10m
|
||||
workspace-pvc Bound pvc-31f15b25-faad-4665-a25f-201a530379af 10Gi RWO hcloud-volumes 13m
|
||||
|
||||
```
|
||||
|
||||
4. Créer un service NodePort.
|
||||
Exemple de commande de création de service ci-dessous :
|
||||
|
||||
```bash
|
||||
# créer le service de type NodePort
|
||||
$ oc create svc nodeport openhands-app-2024 --tcp=3000:3000
|
||||
service/openhands-app-2024 created
|
||||
|
||||
# vérifier
|
||||
|
||||
$ oc get svc
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
openhands-app-2024 NodePort 172.30.225.42 <none> 3000:30495/TCP 4s
|
||||
|
||||
$ oc describe svc openhands-app-2024
|
||||
Name: openhands-app-2024
|
||||
Namespace: openhands
|
||||
Labels: app=openhands-app-2024
|
||||
Annotations: <none>
|
||||
Selector: app=openhands-app-2024
|
||||
Type: NodePort
|
||||
IP Family Policy: SingleStack
|
||||
IP Families: IPv4
|
||||
IP: 172.30.225.42
|
||||
IPs: 172.30.225.42
|
||||
Port: 3000-3000 3000/TCP
|
||||
TargetPort: 3000/TCP
|
||||
NodePort: 3000-3000 30495/TCP
|
||||
Endpoints: 10.128.2.48:3000
|
||||
Session Affinity: None
|
||||
External Traffic Policy: Cluster
|
||||
Events: <none>
|
||||
```
|
||||
|
||||
6. Se connecter à l'interface utilisateur d'OpenHands, configurer l'Agent, puis tester :
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## Déploiement d'Openhands sur GCP GKE
|
||||
|
||||
**Avertissement** : ce déploiement accorde à l'application OpenHands l'accès au socket docker de Kubernetes, ce qui crée un risque de sécurité. Utilisez à vos propres risques.
|
||||
1- Créer une politique pour l'accès privilégié
|
||||
2- Créer des informations d'identification gke (facultatif)
|
||||
3- Créer le déploiement openhands
|
||||
4- Commandes de vérification et d'accès à l'interface utilisateur
|
||||
5- Dépanner le pod pour vérifier le conteneur interne
|
||||
|
||||
1. créer une politique pour l'accès privilégié
|
||||
```bash
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: privileged-role
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec"]
|
||||
verbs: ["create"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: privileged-role-binding
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: privileged-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: default # Remplacez par le nom de votre compte de service
|
||||
namespace: default
|
||||
```
|
||||
2. créer des informations d'identification gke (facultatif)
|
||||
```bash
|
||||
kubectl create secret generic google-cloud-key \
|
||||
--from-file=key.json=/path/to/your/google-cloud-key.json
|
||||
```
|
||||
3. créer le déploiement openhands
|
||||
## comme cela est testé pour le nœud worker unique, si vous en avez plusieurs, spécifiez l'indicateur pour le worker unique
|
||||
|
||||
```bash
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
replicas: 1 # Vous pouvez augmenter ce nombre pour plusieurs réplicas
|
||||
selector:
|
||||
matchLabels:
|
||||
app: openhands-app-2024
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
-
|
||||
@@ -1,343 +0,0 @@
|
||||
以下是翻译后的内容:
|
||||
|
||||
# Kubernetes
|
||||
|
||||
在 Kubernetes 或 OpenShift 上运行 OpenHands 有不同的方式。本指南介绍了一种可能的方式:
|
||||
1. 作为集群管理员,创建一个 PV 将 workspace_base 数据和 docker 目录映射到 worker 节点上的 pod
|
||||
2. 创建一个 PVC 以便将这些 PV 挂载到 pod
|
||||
3. 创建一个包含两个容器的 pod:OpenHands 和 Sandbox 容器
|
||||
|
||||
## 上述示例的详细步骤
|
||||
|
||||
> 注意:确保首先使用适当的帐户登录到集群以执行每个步骤。创建 PV 需要集群管理员权限!
|
||||
|
||||
> 确保你对下面使用的 hostPath(即 /tmp/workspace)有读写权限
|
||||
|
||||
1. 创建 PV:
|
||||
集群管理员可以使用下面的示例 yaml 文件创建 PV。
|
||||
- workspace-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: workspace-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /tmp/workspace
|
||||
```
|
||||
|
||||
```bash
|
||||
# 应用 yaml 文件
|
||||
$ oc create -f workspace-pv.yaml
|
||||
persistentvolume/workspace-pv created
|
||||
|
||||
# 查看:
|
||||
$ oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
- docker-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: docker-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
```
|
||||
|
||||
```bash
|
||||
# 应用 yaml 文件
|
||||
$ oc create -f docker-pv.yaml
|
||||
persistentvolume/docker-pv created
|
||||
|
||||
# 查看:
|
||||
oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
docker-pv 2Gi RWO Retain Available 6m55s
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
2. 创建 PVC:
|
||||
下面是示例 PVC yaml 文件:
|
||||
|
||||
- workspace-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: workspace-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# 创建 pvc
|
||||
$ oc create -f workspace-pvc.yaml
|
||||
persistentvolumeclaim/workspace-pvc created
|
||||
|
||||
# 查看
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
workspace-pvc Pending hcloud-volumes 4s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
8s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
- docker-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: docker-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# 创建 pvc
|
||||
$ oc create -f docker-pvc.yaml
|
||||
persistentvolumeclaim/docker-pvc created
|
||||
|
||||
# 查看
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Pending hcloud-volumes 4s
|
||||
workspace-pvc Pending hcloud-volumes 2m53s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
3. 创建 pod yaml 文件:
|
||||
下面是示例 pod yaml 文件:
|
||||
|
||||
- pod.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: ghcr.io/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: WORKSPACE_MOUNT_PATH
|
||||
value: "/opt/workspace_base"
|
||||
volumeMounts:
|
||||
- name: workspace-volume
|
||||
mountPath: /opt/workspace_base
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- name: openhands-sandbox-2024
|
||||
image: ghcr.io/all-hands-ai/sandbox:main
|
||||
ports:
|
||||
- containerPort: 51963
|
||||
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
|
||||
volumes:
|
||||
- name: workspace-volume
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-pvc
|
||||
- name: docker-sock
|
||||
persistentVolumeClaim:
|
||||
claimName: docker-pvc
|
||||
```
|
||||
|
||||
|
||||
```bash
|
||||
# 创建 pod
|
||||
$ oc create -f pod.yaml
|
||||
W0716 11:22:07.776271 107626 warnings.go:70] would violate PodSecurity "restricted:v1.24": allowPrivilegeEscalation != false (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.runAsNonRoot=true), seccompProfile (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
|
||||
pod/openhands-app-2024 created
|
||||
|
||||
# 上面的警告可以暂时忽略,因为我们不会修改 SCC 限制。
|
||||
|
||||
# 查看
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 Pending 0 5s
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 ContainerCreating 0 15s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
38s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
23s Normal ExternalProvisioning persistentvolumeclaim/docker-pvc waiting for a volume to be created, either by external provisioner "csi.hetzner.cloud" or manually created by system administrator
|
||||
27s Normal Provisioning persistentvolumeclaim/docker-pvc External provisioner is provisioning volume for claim "openhands/docker-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/docker-pvc Successfully provisioned volume pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252
|
||||
16s Normal Scheduled pod/openhands-app-2024 Successfully assigned All-Hands-AI/OpenHands-app-2024 to worker1.hub.internal.blakane.com
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252"
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-31f15b25-faad-4665-a25f-201a530379af"
|
||||
6s Normal AddedInterface pod/openhands-app-2024 Add eth0 [10.128.2.48/23] from openshift-sdn
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/openhands:main" already present on machine
|
||||
6s Normal Created pod/openhands-app-2024 Created container openhands-app-2024
|
||||
6s Normal Started pod/openhands-app-2024 Started container openhands-app-2024
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/sandbox:main" already present on machine
|
||||
5s Normal Created pod/openhands-app-2024 Created container openhands-sandbox-2024
|
||||
5s Normal Started pod/openhands-app-2024 Started container openhands-sandbox-2024
|
||||
83s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
27s Normal Provisioning persistentvolumeclaim/workspace-pvc External provisioner is provisioning volume for claim "openhands/workspace-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/workspace-pvc Successfully provisioned volume pvc-31f15b25-faad-4665-a25f-201a530379af
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 2/2 Running 0 23s
|
||||
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Bound pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252 10Gi RWO hcloud-volumes 10m
|
||||
workspace-pvc Bound pvc-31f15b25-faad-4665-a25f-201a530379af 10Gi RWO hcloud-volumes 13m
|
||||
|
||||
```
|
||||
|
||||
4. 创建一个 NodePort 服务。
|
||||
下面是示例服务创建命令:
|
||||
|
||||
```bash
|
||||
# 创建 NodePort 类型的服务
|
||||
$ oc create svc nodeport openhands-app-2024 --tcp=3000:3000
|
||||
service/openhands-app-2024 created
|
||||
|
||||
# 查看
|
||||
|
||||
$ oc get svc
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
openhands-app-2024 NodePort 172.30.225.42 <none> 3000:30495/TCP 4s
|
||||
|
||||
$ oc describe svc openhands-app-2024
|
||||
Name: openhands-app-2024
|
||||
Namespace: openhands
|
||||
Labels: app=openhands-app-2024
|
||||
Annotations: <none>
|
||||
Selector: app=openhands-app-2024
|
||||
Type: NodePort
|
||||
IP Family Policy: SingleStack
|
||||
IP Families: IPv4
|
||||
IP: 172.30.225.42
|
||||
IPs: 172.30.225.42
|
||||
Port: 3000-3000 3000/TCP
|
||||
TargetPort: 3000/TCP
|
||||
NodePort: 3000-3000 30495/TCP
|
||||
Endpoints: 10.128.2.48:3000
|
||||
Session Affinity: None
|
||||
External Traffic Policy: Cluster
|
||||
Events: <none>
|
||||
```
|
||||
|
||||
6. 连接到 OpenHands UI,配置 Agent,然后测试:
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## GCP GKE OpenHands 部署
|
||||
|
||||
**警告**:此部署授予 OpenHands 应用程序访问 Kubernetes docker socket 的权限,这会带来安全风险。请自行决定是否使用。
|
||||
1- 创建特权访问策略
|
||||
2- 创建 gke 凭证(可选)
|
||||
3- 创建 openhands 部署
|
||||
4- 验证和 UI 访问命令
|
||||
5- 排查 pod 以验证内部容器
|
||||
|
||||
1. 创建特权访问策略
|
||||
```bash
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: privileged-role
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec"]
|
||||
verbs: ["create"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: privileged-role-binding
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: privileged-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: default # 更改为你的服务帐户名称
|
||||
namespace: default
|
||||
```
|
||||
2. 创建 gke 凭证(可选)
|
||||
```bash
|
||||
kubectl create secret generic google-cloud-key \
|
||||
--from-file=key.json=/path/to/your/google-cloud-key.json
|
||||
```
|
||||
3. 创建 openhands 部署
|
||||
## 由于这是针对单个工作节点进行测试的,如果你有多个节点,请指定单个工作节点的标志
|
||||
|
||||
```bash
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
replicas: 1 # 你可以增加这个数字以获得多个副本
|
||||
selector:
|
||||
matchLabels:
|
||||
app: openhands-app-2024
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: ghcr.io/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: SANDBOX_API
|
||||
@@ -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.15-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-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.15 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -39,23 +39,28 @@ You can provide custom directions for OpenHands by following the [README for the
|
||||
|
||||
### Custom configurations
|
||||
|
||||
Github resolver will automatically check for valid [repository secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions?tool=webui#creating-secrets-for-a-repository) or [repository variables](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#creating-configuration-variables-for-a-repository) to customize its behavior. The customization options you can set are:
|
||||
Github resolver will automatically check for valid [repository secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions?tool=webui#creating-secrets-for-a-repository) or [repository variables](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#creating-configuration-variables-for-a-repository) to customize its behavior.
|
||||
The customization options you can set are:
|
||||
|
||||
| **Attribute name** | **Type** | **Purpose** | **Example** |
|
||||
| -------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` |
|
||||
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
|
||||
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
|
||||
| **Attribute name** | **Type** | **Purpose** | **Example** |
|
||||
|----------------------------------| -------- |-------------------------------------------------------------------------------------------------------------|------------------------------------------------------|
|
||||
| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` |
|
||||
| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` |
|
||||
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
|
||||
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
|
||||
|
||||
## Writing Effective .openhands_instructions Files
|
||||
|
||||
The `.openhands_instructions` file is a file that you can put in the root directory of your repository to guide OpenHands in understanding and working with your repository effectively. Here are key tips for writing high-quality instructions:
|
||||
The `.openhands_instructions` file is a file that you can put in the root directory of your repository to guide OpenHands
|
||||
in understanding and working with your repository effectively. Here are key tips for writing high-quality instructions:
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Concise but Informative**: Provide a clear, focused overview of the repository that emphasizes the most common actions OpenHands will need to perform.
|
||||
1. **Concise but Informative**: Provide a clear, focused overview of the repository that emphasizes the most common
|
||||
actions OpenHands will need to perform.
|
||||
|
||||
2. **Repository Structure**: Explain the key directories and their purposes, especially highlighting where different types of code (e.g., frontend, backend) are located.
|
||||
2. **Repository Structure**: Explain the key directories and their purposes, especially highlighting where different
|
||||
types of code (e.g., frontend, backend) are located.
|
||||
|
||||
3. **Development Workflows**: Document the essential commands for:
|
||||
|
||||
|
||||
@@ -44,7 +44,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.15-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -54,6 +54,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.15 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
# Kubernetes
|
||||
|
||||
There are different ways you might run OpenHands on Kubernetes or OpenShift. This guide goes through one possible way:
|
||||
1. Create a PV "as a cluster admin" to map workspace_base data and docker directory to the pod through the worker node
|
||||
2. Create a PVC to be able to mount those PVs to the pod
|
||||
3. Create a pod which contains two containers; the OpenHands and Sandbox containers
|
||||
|
||||
## Detailed Steps for the Example Above
|
||||
|
||||
> Note: Make sure you are logged in to the cluster first with the proper account for each step. PV creation requires cluster administrator!
|
||||
|
||||
> Make sure you have read/write permissions on the hostPath used below (i.e. /tmp/workspace)
|
||||
|
||||
1. Create the PV:
|
||||
Sample yaml file below can be used by a cluster admin to create the PV.
|
||||
- workspace-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: workspace-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /tmp/workspace
|
||||
```
|
||||
|
||||
```bash
|
||||
# apply yaml file
|
||||
$ oc create -f workspace-pv.yaml
|
||||
persistentvolume/workspace-pv created
|
||||
|
||||
# review:
|
||||
$ oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
- docker-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: docker-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
```
|
||||
|
||||
```bash
|
||||
# apply yaml file
|
||||
$ oc create -f docker-pv.yaml
|
||||
persistentvolume/docker-pv created
|
||||
|
||||
# review:
|
||||
oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
docker-pv 2Gi RWO Retain Available 6m55s
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
2. Create the PVC:
|
||||
Sample PVC yaml file below:
|
||||
|
||||
- workspace-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: workspace-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# create the pvc
|
||||
$ oc create -f workspace-pvc.yaml
|
||||
persistentvolumeclaim/workspace-pvc created
|
||||
|
||||
# review
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
workspace-pvc Pending hcloud-volumes 4s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
8s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
- docker-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: docker-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# create pvc
|
||||
$ oc create -f docker-pvc.yaml
|
||||
persistentvolumeclaim/docker-pvc created
|
||||
|
||||
# review
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Pending hcloud-volumes 4s
|
||||
workspace-pvc Pending hcloud-volumes 2m53s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
3. Create the pod yaml file:
|
||||
Sample pod yaml file below:
|
||||
|
||||
- pod.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: docker.all-hands.dev/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: WORKSPACE_MOUNT_PATH
|
||||
value: "/opt/workspace_base"
|
||||
volumeMounts:
|
||||
- name: workspace-volume
|
||||
mountPath: /opt/workspace_base
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- name: openhands-sandbox-2024
|
||||
image: docker.all-hands.dev/all-hands-ai/runtime:main
|
||||
ports:
|
||||
- containerPort: 51963
|
||||
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
|
||||
volumes:
|
||||
- name: workspace-volume
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-pvc
|
||||
- name: docker-sock
|
||||
persistentVolumeClaim:
|
||||
claimName: docker-pvc
|
||||
```
|
||||
|
||||
|
||||
```bash
|
||||
# create the pod
|
||||
$ oc create -f pod.yaml
|
||||
W0716 11:22:07.776271 107626 warnings.go:70] would violate PodSecurity "restricted:v1.24": allowPrivilegeEscalation != false (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.runAsNonRoot=true), seccompProfile (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
|
||||
pod/openhands-app-2024 created
|
||||
|
||||
# Above warning can be ignored for now as we will not modify SCC restrictions.
|
||||
|
||||
# review
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 Pending 0 5s
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 ContainerCreating 0 15s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
38s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
23s Normal ExternalProvisioning persistentvolumeclaim/docker-pvc waiting for a volume to be created, either by external provisioner "csi.hetzner.cloud" or manually created by system administrator
|
||||
27s Normal Provisioning persistentvolumeclaim/docker-pvc External provisioner is provisioning volume for claim "openhands/docker-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/docker-pvc Successfully provisioned volume pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252
|
||||
16s Normal Scheduled pod/openhands-app-2024 Successfully assigned All-Hands-AI/OpenHands-app-2024 to worker1.hub.internal.blakane.com
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252"
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-31f15b25-faad-4665-a25f-201a530379af"
|
||||
6s Normal AddedInterface pod/openhands-app-2024 Add eth0 [10.128.2.48/23] from openshift-sdn
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "docker.all-hands.dev/all-hands-ai/openhands:main" already present on machine
|
||||
6s Normal Created pod/openhands-app-2024 Created container openhands-app-2024
|
||||
6s Normal Started pod/openhands-app-2024 Started container openhands-app-2024
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "docker.all-hands.dev/all-hands-ai/sandbox:main" already present on machine
|
||||
5s Normal Created pod/openhands-app-2024 Created container openhands-sandbox-2024
|
||||
5s Normal Started pod/openhands-app-2024 Started container openhands-sandbox-2024
|
||||
83s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
27s Normal Provisioning persistentvolumeclaim/workspace-pvc External provisioner is provisioning volume for claim "openhands/workspace-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/workspace-pvc Successfully provisioned volume pvc-31f15b25-faad-4665-a25f-201a530379af
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 2/2 Running 0 23s
|
||||
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Bound pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252 10Gi RWO hcloud-volumes 10m
|
||||
workspace-pvc Bound pvc-31f15b25-faad-4665-a25f-201a530379af 10Gi RWO hcloud-volumes 13m
|
||||
|
||||
```
|
||||
|
||||
4. Create a NodePort service.
|
||||
Sample service creation command below:
|
||||
|
||||
```bash
|
||||
# create the service of type NodePort
|
||||
$ oc create svc nodeport openhands-app-2024 --tcp=3000:3000
|
||||
service/openhands-app-2024 created
|
||||
|
||||
# review
|
||||
|
||||
$ oc get svc
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
openhands-app-2024 NodePort 172.30.225.42 <none> 3000:30495/TCP 4s
|
||||
|
||||
$ oc describe svc openhands-app-2024
|
||||
Name: openhands-app-2024
|
||||
Namespace: openhands
|
||||
Labels: app=openhands-app-2024
|
||||
Annotations: <none>
|
||||
Selector: app=openhands-app-2024
|
||||
Type: NodePort
|
||||
IP Family Policy: SingleStack
|
||||
IP Families: IPv4
|
||||
IP: 172.30.225.42
|
||||
IPs: 172.30.225.42
|
||||
Port: 3000-3000 3000/TCP
|
||||
TargetPort: 3000/TCP
|
||||
NodePort: 3000-3000 30495/TCP
|
||||
Endpoints: 10.128.2.48:3000
|
||||
Session Affinity: None
|
||||
External Traffic Policy: Cluster
|
||||
Events: <none>
|
||||
```
|
||||
|
||||
6. Connect to OpenHands UI, configure the Agent, then test:
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## GCP GKE Openhands deployment
|
||||
|
||||
**Warning**: this deployment grants the OpenHands application access to the Kubernetes docker socket, which creates security risk. Use at your own discretion.
|
||||
1- Create policy for privillege access
|
||||
2- Create gke credentials(optional)
|
||||
3- Create openhands deployment
|
||||
4- Verification and ui access commands
|
||||
5- Tshoot pod to verify the internal container
|
||||
|
||||
1. create policy for privillege access
|
||||
```bash
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: privileged-role
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec"]
|
||||
verbs: ["create"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: privileged-role-binding
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: privileged-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: default # Change to your service account name
|
||||
namespace: default
|
||||
```
|
||||
2. create gke credentials(optional)
|
||||
```bash
|
||||
kubectl create secret generic google-cloud-key \
|
||||
--from-file=key.json=/path/to/your/google-cloud-key.json
|
||||
```
|
||||
3. create openhands deployment
|
||||
## as this is tested for the single worker node if you have multiple specify the flag for the single worker
|
||||
|
||||
```bash
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
replicas: 1 # You can increase this number for multiple replicas
|
||||
selector:
|
||||
matchLabels:
|
||||
app: openhands-app-2024
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: docker.all-hands.dev/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: SANDBOX_API_HOSTNAME
|
||||
value: '10.164.0.4'
|
||||
- name: WORKSPACE_MOUNT_PATH
|
||||
value: "/tmp/workspace_base"
|
||||
- name: GOOGLE_APPLICATION_CREDENTIALS
|
||||
value: "/tmp/workspace_base/google-cloud-key.json"
|
||||
volumeMounts:
|
||||
- name: workspace-volume
|
||||
mountPath: /tmp/workspace_base
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
- name: google-credentials
|
||||
mountPath: "/tmp/workspace_base/google-cloud-key.json"
|
||||
securityContext:
|
||||
privileged: true # Add this to allow privileged access
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- name: openhands-sandbox-2024
|
||||
image: docker.all-hands.dev/all-hands-ai/runtime:main
|
||||
# securityContext:
|
||||
# privileged: true # Add this to allow privileged access
|
||||
ports:
|
||||
- containerPort: 51963
|
||||
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
|
||||
volumes:
|
||||
#- name: workspace-volume
|
||||
# persistentVolumeClaim:
|
||||
# claimName: workspace-pvc
|
||||
- name: workspace-volume
|
||||
emptyDir: {}
|
||||
- name: docker-sock
|
||||
hostPath:
|
||||
path: /var/run/docker.sock # Use host's Docker socket
|
||||
type: Socket
|
||||
- name: google-credentials
|
||||
secret:
|
||||
secretName: google-cloud-key
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: openhands-app-2024-svc
|
||||
spec:
|
||||
selector:
|
||||
app: openhands-app-2024
|
||||
ports:
|
||||
- name: http
|
||||
protocol: TCP
|
||||
port: 80
|
||||
targetPort: 3000
|
||||
- name: ssh
|
||||
protocol: TCP
|
||||
port: 51963
|
||||
targetPort: 51963
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
5. Tshoot pod to verify the internal container
|
||||
### if you want to know more regarding the internal container runtime use below mention pod deployment use kubectl exec -it to enter into container and you can check the contaienr run time using normal docker commands like "docker ps -a"
|
||||
|
||||
```bash
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: docker-in-docker
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: docker-in-docker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: docker-in-docker
|
||||
spec:
|
||||
containers:
|
||||
- name: dind
|
||||
image: docker:20.10-dind
|
||||
securityContext:
|
||||
privileged: true
|
||||
volumeMounts:
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
volumes:
|
||||
- name: docker-sock
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
type: Socket
|
||||
```
|
||||
@@ -11,16 +11,16 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-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.15
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.16
|
||||
```
|
||||
|
||||
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
@@ -28,12 +28,22 @@ You can also [build your own runtime image](how-to/custom-sandbox-guide).
|
||||
### Connecting to Your filesystem
|
||||
One useful feature here is the ability to connect to your local filesystem.
|
||||
|
||||
To mount your filesystem into the runtime, add the following options to
|
||||
the `docker run` command:
|
||||
|
||||
To mount your filesystem into the runtime, first set WORKSPACE_BASE:
|
||||
```bash
|
||||
export WORKSPACE_BASE=/path/to/your/code
|
||||
|
||||
# Linux and Mac Example
|
||||
# export WORKSPACE_BASE=$HOME/OpenHands
|
||||
# Will set $WORKSPACE_BASE to /home/<username>/OpenHands
|
||||
#
|
||||
# WSL on Windows Example
|
||||
# export WORKSPACE_BASE=/mnt/c/dev/OpenHands
|
||||
# Will set $WORKSPACE_BASE to C:\dev\OpenHands
|
||||
```
|
||||
|
||||
then add the following options to the `docker run` command:
|
||||
|
||||
```bash
|
||||
docker run # ...
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
|
||||
8
docs/package-lock.json
generated
8
docs/package-lock.json
generated
@@ -14,7 +14,7 @@
|
||||
"@docusaurus/theme-mermaid": "^3.6.3",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
@@ -14781,9 +14781,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prism-react-renderer": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.0.tgz",
|
||||
"integrity": "sha512-327BsVCD/unU4CNLZTWVHyUHKnsqcvj2qbPlQ8MiBE2eq2rgctjigPA1Gp9HLF83kZ20zNN6jgizHJeEsyFYOw==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
|
||||
"integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"clsx": "^2.0.0"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@docusaurus/theme-mermaid": "^3.6.3",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
|
||||
@@ -168,11 +168,6 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Evaluation',
|
||||
id: 'usage/how-to/evaluation-harness',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Kubernetes Deployment',
|
||||
id: 'usage/how-to/openshift-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -202,6 +202,9 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -307,6 +307,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
@@ -279,6 +279,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
@@ -328,6 +328,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
@@ -456,6 +456,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -142,6 +142,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
@@ -571,6 +571,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
llm_config.log_completions = True
|
||||
|
||||
if llm_config is None:
|
||||
|
||||
@@ -466,6 +466,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -238,6 +238,9 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -326,6 +326,9 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -285,6 +285,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ if __name__ == '__main__':
|
||||
default='ProofWriter',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--data_split',
|
||||
'--data-split',
|
||||
type=str,
|
||||
help='data split to evaluate on {validation}', # right now we only support validation split
|
||||
default='validation',
|
||||
@@ -288,6 +288,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -279,6 +279,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -124,6 +124,9 @@ if __name__ == '__main__':
|
||||
# for details of how to set `llm_config`
|
||||
if args.llm_config:
|
||||
specified_llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
specified_llm_config.modify_params = False
|
||||
|
||||
if specified_llm_config:
|
||||
config.llm = specified_llm_config
|
||||
logger.info(f'Config for evaluation: {config}')
|
||||
|
||||
@@ -292,6 +292,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ If the program uses some packages that are incompatible, please figure out alter
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--use_knowledge',
|
||||
'--use-knowledge',
|
||||
type=str,
|
||||
default='false',
|
||||
choices=['true', 'false'],
|
||||
@@ -272,6 +272,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ echo "MODEL_CONFIG: $MODEL_CONFIG"
|
||||
COMMAND="poetry run python evaluation/benchmarks/scienceagentbench/run_infer.py \
|
||||
--agent-cls $AGENT \
|
||||
--llm-config $MODEL_CONFIG \
|
||||
--use_knowledge $USE_KNOWLEDGE \
|
||||
--use-knowledge $USE_KNOWLEDGE \
|
||||
--max-iterations 30 \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--eval-note $OPENHANDS_VERSION" \
|
||||
|
||||
@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -369,6 +370,7 @@ def process_instance(
|
||||
instance: pd.Series,
|
||||
metadata: EvalMetadata,
|
||||
reset_logger: bool = True,
|
||||
runtime_failure_count: int = 0,
|
||||
) -> EvalOutput:
|
||||
config = get_config(instance, metadata)
|
||||
|
||||
@@ -379,6 +381,15 @@ def process_instance(
|
||||
else:
|
||||
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
|
||||
|
||||
# Increase resource_factor with increasing attempt_id
|
||||
if runtime_failure_count > 0:
|
||||
config.sandbox.remote_runtime_resource_factor = min(
|
||||
config.sandbox.remote_runtime_resource_factor * (2**runtime_failure_count),
|
||||
2, # hardcode maximum resource factor to 2
|
||||
)
|
||||
logger.warning(
|
||||
f'This is the second attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
|
||||
)
|
||||
runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
@@ -400,11 +411,7 @@ def process_instance(
|
||||
)
|
||||
|
||||
# if fatal error, throw EvalError to trigger re-run
|
||||
if (
|
||||
state.last_error
|
||||
and 'fatal error during agent execution' in state.last_error
|
||||
and 'stuck in a loop' not in state.last_error
|
||||
):
|
||||
if is_fatal_evaluation_error(state.last_error):
|
||||
raise EvalException('Fatal error detected: ' + state.last_error)
|
||||
|
||||
# ======= THIS IS SWE-Bench specific =======
|
||||
@@ -490,6 +497,8 @@ if __name__ == '__main__':
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
llm_config.log_completions = True
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
@@ -6,6 +6,8 @@ import os
|
||||
from collections import Counter
|
||||
|
||||
import pandas as pd
|
||||
import random
|
||||
import numpy as np
|
||||
|
||||
from openhands.events.serialization import event_from_dict
|
||||
from openhands.events.utils import get_pairs_from_events
|
||||
@@ -18,6 +20,18 @@ ERROR_KEYWORDS = [
|
||||
]
|
||||
|
||||
|
||||
def get_bootstrap_accuracy_error_bars(values: float | int | bool, num_samples: int = 1000, p_value=0.05) -> tuple[float, float]:
|
||||
sorted_vals = np.sort(
|
||||
[
|
||||
np.mean(random.sample(values, len(values) // 2))
|
||||
for _ in range(num_samples)
|
||||
]
|
||||
)
|
||||
bottom_idx = int(num_samples * p_value / 2)
|
||||
top_idx = int(num_samples * (1.0 - p_value / 2))
|
||||
return (sorted_vals[bottom_idx], sorted_vals[top_idx])
|
||||
|
||||
|
||||
def process_file(file_path):
|
||||
with open(file_path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
@@ -26,6 +40,7 @@ def process_file(file_path):
|
||||
num_error_lines = 0
|
||||
num_agent_stuck_in_loop = 0
|
||||
num_resolved = 0
|
||||
resolved_arr = []
|
||||
num_empty_patch = 0
|
||||
num_unfinished_runs = 0
|
||||
error_counter = Counter()
|
||||
@@ -74,6 +89,9 @@ def process_file(file_path):
|
||||
resolved = report.get('resolved', False)
|
||||
if resolved:
|
||||
num_resolved += 1
|
||||
resolved_arr.append(1)
|
||||
else:
|
||||
resolved_arr.append(0)
|
||||
|
||||
# Error
|
||||
error = _d.get('error', None)
|
||||
@@ -100,6 +118,7 @@ def process_file(file_path):
|
||||
'resolved': {
|
||||
'count': num_resolved,
|
||||
'percentage': (num_resolved / num_lines * 100) if num_lines > 0 else 0,
|
||||
'ci': tuple(x * 100 for x in get_bootstrap_accuracy_error_bars(resolved_arr)),
|
||||
},
|
||||
'empty_patches': {
|
||||
'count': num_empty_patch,
|
||||
@@ -174,6 +193,7 @@ def aggregate_directory(input_path) -> pd.DataFrame:
|
||||
)
|
||||
|
||||
df['resolve_rate'] = df['resolved'].apply(lambda x: x['percentage'])
|
||||
df['resolve_rate_ci'] = df['resolved'].apply(lambda x: x['ci'])
|
||||
df['empty_patch_rate'] = df['empty_patches'].apply(lambda x: x['percentage'])
|
||||
df['unfinished_rate'] = df['unfinished_runs'].apply(lambda x: x['percentage'])
|
||||
df['avg_turns'] = df['statistics'].apply(lambda x: x['avg_turns'])
|
||||
@@ -242,7 +262,7 @@ if __name__ == '__main__':
|
||||
# Print detailed results for single file
|
||||
print(f'\nResults for {args.input_path}:')
|
||||
print(
|
||||
f"Number of resolved: {result['resolved']['count']} / {result['total_instances']} ({result['resolved']['percentage']:.2f}%)"
|
||||
f"Number of resolved: {result['resolved']['count']} / {result['total_instances']} ({result['resolved']['percentage']:.2f}% [{result['resolved']['ci'][0]:.2f}%, {result['resolved']['ci'][1]:.2f}%])"
|
||||
)
|
||||
print(
|
||||
f"Number of empty patch: {result['empty_patches']['count']} / {result['total_instances']} ({result['empty_patches']['percentage']:.2f}%)"
|
||||
|
||||
@@ -33,7 +33,7 @@ if [ -d /workspace/$WORKSPACE_NAME ]; then
|
||||
rm -rf /workspace/$WORKSPACE_NAME
|
||||
fi
|
||||
mkdir -p /workspace
|
||||
mv /testbed /workspace/$WORKSPACE_NAME
|
||||
cp -r /testbed /workspace/$WORKSPACE_NAME
|
||||
|
||||
# Activate instance-specific environment
|
||||
. /opt/miniconda3/etc/profile.d/conda.sh
|
||||
|
||||
@@ -11,7 +11,7 @@ Please follow instruction [here](../../README.md#setup) to setup your local deve
|
||||
Make sure your Docker daemon is running, then run this bash script:
|
||||
|
||||
```bash
|
||||
bash evaluation/benchmarks/toolqa/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [dataset] [hardness] [wolfram_alpha_appid]
|
||||
bash evaluation/benchmarks/toolqa/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [dataset] [hardness] [wolfram-alpha-appid]
|
||||
```
|
||||
|
||||
where `model_config` is mandatory, while all other arguments are optional.
|
||||
@@ -32,7 +32,7 @@ By default, the script evaluates 1 instance.
|
||||
|
||||
`hardness`, the hardness to evaluate. You could choose from `easy` and `hard`. The default is `easy`.
|
||||
|
||||
`wolfram_alpha_appid` is an optional argument. When given `wolfram_alpha_appid`, the agent will be able to access Wolfram Alpha's APIs.
|
||||
`wolfram-alpha-appid` is an optional argument. When given `wolfram-alpha-appid`, the agent will be able to access Wolfram Alpha's APIs.
|
||||
|
||||
Note: in order to use `eval_limit`, you must also set `agent`; in order to use `dataset`, you must also set `eval_limit`; in order to use `hardness`, you must also set `dataset`.
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ if __name__ == '__main__':
|
||||
default='easy',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--wolfram_alpha_appid',
|
||||
'--wolfram-alpha-appid',
|
||||
type=str,
|
||||
help='wolfram alpha appid to use for wolfram alpha related tests',
|
||||
default='YOUR_WOLFRAMALPHA_APPID',
|
||||
@@ -181,6 +181,9 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ COMMAND="poetry run python evaluation/benchmarks/toolqa/run_infer.py \
|
||||
--max-iterations 30 \
|
||||
--dataset $DATASET \
|
||||
--hardness $HARDNESS \
|
||||
--wolfram_alpha_appid $WOLFRAM_APPID\
|
||||
--wolfram-alpha-appid $WOLFRAM_APPID\
|
||||
--data-split validation \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--eval-note ${OPENHANDS_VERSION}_${LEVELS}"
|
||||
|
||||
@@ -212,6 +212,8 @@ if __name__ == '__main__':
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accurancy of results
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import subprocess
|
||||
import time
|
||||
import traceback
|
||||
from contextlib import contextmanager
|
||||
from inspect import signature
|
||||
from typing import Any, Awaitable, Callable, TextIO
|
||||
|
||||
import pandas as pd
|
||||
@@ -16,6 +17,15 @@ from tqdm import tqdm
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.exceptions import (
|
||||
AgentRuntimeBuildError,
|
||||
AgentRuntimeDisconnectedError,
|
||||
AgentRuntimeError,
|
||||
AgentRuntimeNotFoundError,
|
||||
AgentRuntimeNotReadyError,
|
||||
AgentRuntimeTimeoutError,
|
||||
AgentRuntimeUnavailableError,
|
||||
)
|
||||
from openhands.core.logger import get_console_handler
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import Action
|
||||
@@ -306,13 +316,20 @@ def _process_instance_wrapper(
|
||||
timeout_seconds: int | None = None,
|
||||
) -> EvalOutput:
|
||||
"""Wrap the process_instance_func to handle retries and errors."""
|
||||
runtime_failure_count = 0
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
kwargs = {}
|
||||
# check if process_instance_func accepts timeout_seconds parameter
|
||||
sig = signature(process_instance_func)
|
||||
if 'runtime_failure_count' in sig.parameters:
|
||||
kwargs['runtime_failure_count'] = runtime_failure_count
|
||||
|
||||
if timeout_seconds is not None:
|
||||
with timeout(timeout_seconds):
|
||||
result = process_instance_func(instance, metadata, use_mp)
|
||||
result = process_instance_func(instance, metadata, use_mp, **kwargs)
|
||||
else:
|
||||
result = process_instance_func(instance, metadata, use_mp)
|
||||
result = process_instance_func(instance, metadata, use_mp, **kwargs)
|
||||
return result
|
||||
except EvalTimeoutException as e:
|
||||
error = f'Timeout after {timeout_seconds} seconds'
|
||||
@@ -358,6 +375,11 @@ def _process_instance_wrapper(
|
||||
+ '-' * 10
|
||||
+ '\n'
|
||||
)
|
||||
if isinstance(
|
||||
e, (AgentRuntimeDisconnectedError, AgentRuntimeUnavailableError)
|
||||
):
|
||||
runtime_failure_count += 1
|
||||
msg += f'Runtime disconnected error detected for instance {instance.instance_id}, runtime failure count: {runtime_failure_count}'
|
||||
logger.error(msg)
|
||||
if use_mp:
|
||||
print(msg) # use print to directly print to console
|
||||
@@ -503,3 +525,24 @@ def compatibility_for_eval_history_pairs(
|
||||
history_pairs.append((event_to_dict(action), event_to_dict(observation)))
|
||||
|
||||
return history_pairs
|
||||
|
||||
|
||||
def is_fatal_evaluation_error(error: str | None) -> bool:
|
||||
if not error:
|
||||
return False
|
||||
|
||||
FATAL_EXCEPTIONS = [
|
||||
AgentRuntimeError,
|
||||
AgentRuntimeBuildError,
|
||||
AgentRuntimeTimeoutError,
|
||||
AgentRuntimeUnavailableError,
|
||||
AgentRuntimeNotReadyError,
|
||||
AgentRuntimeDisconnectedError,
|
||||
AgentRuntimeNotFoundError,
|
||||
]
|
||||
|
||||
if any(exception.__name__ in error for exception in FATAL_EXCEPTIONS):
|
||||
logger.error(f'Fatal evaluation error detected: {error}')
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
|
||||
// Mock useParams before importing components
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual as object,
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual as object,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { BrowserPanel } from "#/components/features/browser/browser";
|
||||
|
||||
|
||||
describe("Browser", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it("renders a message if no screenshotSrc is provided", () => {
|
||||
renderWithProviders(<BrowserPanel />, {
|
||||
preloadedState: {
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("Empty state", () => {
|
||||
const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
|
||||
useWsClient: vi.fn(() => ({
|
||||
send: sendMock,
|
||||
status: WsClientProviderStatus.ACTIVE,
|
||||
status: WsClientProviderStatus.CONNECTED,
|
||||
isLoadingMessages: false,
|
||||
})),
|
||||
}));
|
||||
@@ -90,7 +90,7 @@ describe("Empty state", () => {
|
||||
// this is to test that the message is in the UI before the socket is called
|
||||
useWsClientMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
status: WsClientProviderStatus.ACTIVE,
|
||||
status: WsClientProviderStatus.CONNECTED,
|
||||
isLoadingMessages: false,
|
||||
}));
|
||||
const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage");
|
||||
@@ -120,7 +120,7 @@ describe("Empty state", () => {
|
||||
async () => {
|
||||
useWsClientMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
status: WsClientProviderStatus.ACTIVE,
|
||||
status: WsClientProviderStatus.CONNECTED,
|
||||
isLoadingMessages: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
@@ -138,7 +138,7 @@ describe("Empty state", () => {
|
||||
|
||||
useWsClientMock.mockImplementation(() => ({
|
||||
send: sendMock,
|
||||
status: WsClientProviderStatus.ACTIVE,
|
||||
status: WsClientProviderStatus.CONNECTED,
|
||||
isLoadingMessages: false,
|
||||
}));
|
||||
rerender(<ChatInterface />);
|
||||
|
||||
@@ -2,12 +2,28 @@ import { describe, expect, it } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ExpandableMessage } from "#/components/features/chat/expandable-message";
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual('react-i18next');
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key:string) => key,
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
language: 'en',
|
||||
exists: () => true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
describe("ExpandableMessage", () => {
|
||||
it("should render with neutral border for non-action messages", () => {
|
||||
renderWithProviders(<ExpandableMessage message="Hello" type="thought" />);
|
||||
const element = screen.getByText("Hello");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-between");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -15,21 +31,22 @@ describe("ExpandableMessage", () => {
|
||||
it("should render with neutral border for error messages", () => {
|
||||
renderWithProviders(<ExpandableMessage message="Error occurred" type="error" />);
|
||||
const element = screen.getByText("Error occurred");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-between");
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
expect(container).toHaveClass("border-danger");
|
||||
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with success icon for successful action messages", () => {
|
||||
renderWithProviders(
|
||||
<ExpandableMessage
|
||||
id="OBSERVATION_MESSAGE$RUN"
|
||||
message="Command executed successfully"
|
||||
type="action"
|
||||
success={true}
|
||||
/>
|
||||
);
|
||||
const element = screen.getByText("Command executed successfully");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-between");
|
||||
const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
const icon = screen.getByTestId("status-icon");
|
||||
expect(icon).toHaveClass("fill-success");
|
||||
@@ -38,22 +55,29 @@ describe("ExpandableMessage", () => {
|
||||
it("should render with error icon for failed action messages", () => {
|
||||
renderWithProviders(
|
||||
<ExpandableMessage
|
||||
id="OBSERVATION_MESSAGE$RUN"
|
||||
message="Command failed"
|
||||
type="action"
|
||||
success={false}
|
||||
/>
|
||||
);
|
||||
const element = screen.getByText("Command failed");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-between");
|
||||
const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
const icon = screen.getByTestId("status-icon");
|
||||
expect(icon).toHaveClass("fill-danger");
|
||||
});
|
||||
|
||||
it("should render with neutral border and no icon for action messages without success prop", () => {
|
||||
renderWithProviders(<ExpandableMessage message="Running command" type="action" />);
|
||||
const element = screen.getByText("Running command");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-between");
|
||||
renderWithProviders(
|
||||
<ExpandableMessage
|
||||
id="OBSERVATION_MESSAGE$RUN"
|
||||
message="Running command"
|
||||
type="action"
|
||||
/>
|
||||
);
|
||||
const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
|
||||
const container = element.closest("div.flex.gap-2.items-center.justify-start");
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
|
||||
// Mock useParams before importing components
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual as object,
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
};
|
||||
});
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { FeedbackForm } from "#/components/features/feedback/feedback-form";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { describe, it, expect, vi, Mock, afterEach } from "vitest";
|
||||
import toast from "#/utils/toast";
|
||||
import AgentState from "#/types/agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
|
||||
|
||||
|
||||
@@ -4,26 +4,6 @@ import { vi, describe, afterEach, it, expect } from "vitest";
|
||||
import { Command, appendInput, appendOutput } from "#/state/command-slice";
|
||||
import Terminal from "#/components/features/terminal/terminal";
|
||||
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTerminal = {
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@xterm/xterm", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@xterm/xterm")>()),
|
||||
Terminal: vi.fn().mockImplementation(() => mockTerminal),
|
||||
}));
|
||||
|
||||
const renderTerminal = (commands: Command[] = []) =>
|
||||
renderWithProviders(<Terminal secrets={[]} />, {
|
||||
preloadedState: {
|
||||
@@ -34,6 +14,26 @@ const renderTerminal = (commands: Command[] = []) =>
|
||||
});
|
||||
|
||||
describe.skip("Terminal", () => {
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTerminal = {
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@xterm/xterm", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@xterm/xterm")>()),
|
||||
Terminal: vi.fn().mockImplementation(() => mockTerminal),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ReactNode } from "react";
|
||||
import { useTerminal } from "#/hooks/use-terminal";
|
||||
import { Command } from "#/state/command-slice";
|
||||
|
||||
|
||||
interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
secrets: string[];
|
||||
@@ -15,7 +14,7 @@ function TestTerminalComponent({
|
||||
commands,
|
||||
secrets,
|
||||
}: TestTerminalComponentProps) {
|
||||
const ref = useTerminal(commands, secrets);
|
||||
const ref = useTerminal({ commands, secrets, disabled: false });
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
@@ -24,9 +23,7 @@ interface WrapperProps {
|
||||
}
|
||||
|
||||
function Wrapper({ children }: WrapperProps) {
|
||||
return (
|
||||
<div>{children}</div>
|
||||
)
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
@@ -39,12 +40,6 @@ describe("frontend/routes/_oh", () => {
|
||||
await screen.findByTestId("root-layout");
|
||||
});
|
||||
|
||||
it("should render the AI config modal if the user is authed", async () => {
|
||||
// Our mock return value is true by default
|
||||
renderWithProviders(<RouteStub />);
|
||||
await screen.findByTestId("ai-config-modal");
|
||||
});
|
||||
|
||||
it("should render the AI config modal if settings are not up-to-date", async () => {
|
||||
settingsAreUpToDateMock.mockReturnValue(false);
|
||||
renderWithProviders(<RouteStub />);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { getToken } from "../../src/services/auth";
|
||||
|
||||
Storage.prototype.getItem = vi.fn();
|
||||
Storage.prototype.setItem = vi.fn();
|
||||
|
||||
describe("Auth Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getToken", () => {
|
||||
it("should fetch and return a token", () => {
|
||||
(Storage.prototype.getItem as Mock).mockReturnValue("newToken");
|
||||
|
||||
const data = getToken();
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith("token"); // Used to set Authorization header
|
||||
expect(data).toEqual("newToken");
|
||||
});
|
||||
});
|
||||
});
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.15.2",
|
||||
"version": "0.16.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.15.2",
|
||||
"version": "0.16.1",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.15.2",
|
||||
"version": "0.16.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"APP_MODE": "oss",
|
||||
"GITHUB_CLIENT_ID": "",
|
||||
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
const github = axios.create({
|
||||
baseURL: "https://api.github.com",
|
||||
@@ -18,4 +18,86 @@ const removeAuthTokenHeader = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export { github, setAuthTokenHeader, removeAuthTokenHeader };
|
||||
/**
|
||||
* Checks if response has attributes to perform refresh
|
||||
*/
|
||||
const canRefresh = (error: unknown): boolean =>
|
||||
!!(
|
||||
error instanceof AxiosError &&
|
||||
error.config &&
|
||||
error.response &&
|
||||
error.response.status
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if the data is a GitHub error response
|
||||
* @param data The data to check
|
||||
* @returns Boolean indicating if the data is a GitHub error response
|
||||
*/
|
||||
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
|
||||
data: T | GitHubErrorReponse | null,
|
||||
): data is GitHubErrorReponse =>
|
||||
!!data && "message" in data && data.message !== undefined;
|
||||
|
||||
// Axios interceptor to handle token refresh
|
||||
const setupAxiosInterceptors = (
|
||||
refreshToken: () => Promise<boolean>,
|
||||
logout: () => void,
|
||||
) => {
|
||||
github.interceptors.response.use(
|
||||
// Pass successful responses through
|
||||
(response) => {
|
||||
const parsedData = response.data;
|
||||
if (isGitHubErrorReponse(parsedData)) {
|
||||
const error = new AxiosError(
|
||||
"Failed",
|
||||
"",
|
||||
response.config,
|
||||
response.request,
|
||||
response,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
// Retry request exactly once if token is expired
|
||||
async (error) => {
|
||||
if (!canRefresh(error)) {
|
||||
return Promise.reject(new Error("Failed to refresh token"));
|
||||
}
|
||||
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Check if the error is due to an expired token
|
||||
if (
|
||||
error.response.status === 401 &&
|
||||
!originalRequest._retry // Prevent infinite retry loops
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
return await github(originalRequest);
|
||||
}
|
||||
|
||||
logout();
|
||||
return await Promise.reject(new Error("Failed to refresh token"));
|
||||
} catch (refreshError) {
|
||||
// If token refresh fails, evict the user
|
||||
logout();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// If the error is not due to an expired token, propagate the error
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
github,
|
||||
setAuthTokenHeader,
|
||||
removeAuthTokenHeader,
|
||||
setupAxiosInterceptors,
|
||||
};
|
||||
|
||||
@@ -1,42 +1,81 @@
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { github } from "./github-axios-instance";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
|
||||
/**
|
||||
* Checks if the data is a GitHub error response
|
||||
* @param data The data to check
|
||||
* @returns Boolean indicating if the data is a GitHub error response
|
||||
* Given the user, retrieves app installations IDs for OpenHands Github App
|
||||
* Uses user access token for Github App
|
||||
*/
|
||||
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
|
||||
data: T | GitHubErrorReponse | null,
|
||||
): data is GitHubErrorReponse =>
|
||||
!!data && "message" in data && data.message !== undefined;
|
||||
export const retrieveGitHubAppInstallations = async (): Promise<number[]> => {
|
||||
const response = await github.get<GithubAppInstallation>(
|
||||
"/user/installations",
|
||||
);
|
||||
|
||||
return response.data.installations.map((installation) => installation.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a GitHub token, retrieves the repositories of the authenticated user
|
||||
* @param token The GitHub token
|
||||
* @returns A list of repositories or an error response
|
||||
* Retrieves repositories where OpenHands Github App has been installed
|
||||
* @param installationIndex Pagination cursor position for app installation IDs
|
||||
* @param installations Collection of all App installation IDs for OpenHands Github App
|
||||
* @returns A list of repositories
|
||||
*/
|
||||
export const retrieveGitHubAppRepositories = async (
|
||||
installationIndex: number,
|
||||
installations: number[],
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) => {
|
||||
const installationId = installations[installationIndex];
|
||||
const response = await openHands.get<GitHubAppRepository>(
|
||||
"/api/github/repositories",
|
||||
{
|
||||
params: {
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
installation_id: installationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const link = response.headers.link ?? "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
let nextInstallation: number | null;
|
||||
|
||||
if (nextPage) {
|
||||
nextInstallation = installationIndex;
|
||||
} else if (installationIndex + 1 < installations.length) {
|
||||
nextInstallation = installationIndex + 1;
|
||||
} else {
|
||||
nextInstallation = null;
|
||||
}
|
||||
|
||||
return {
|
||||
data: response.data.repositories,
|
||||
nextPage,
|
||||
installationIndex: nextInstallation,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a PAT, retrieves the repositories of the user
|
||||
* @returns A list of repositories
|
||||
*/
|
||||
export const retrieveGitHubUserRepositories = async (
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) => {
|
||||
const response = await github.get<GitHubRepository[]>("/user/repos", {
|
||||
params: {
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
const response = await openHands.get<GitHubRepository[]>(
|
||||
"/api/github/repositories",
|
||||
{
|
||||
params: {
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
},
|
||||
},
|
||||
transformResponse: (data) => {
|
||||
const parsedData: GitHubRepository[] | GitHubErrorReponse =
|
||||
JSON.parse(data);
|
||||
|
||||
if (isGitHubErrorReponse(parsedData)) {
|
||||
throw new Error(parsedData.message);
|
||||
}
|
||||
|
||||
return parsedData;
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const link = response.headers.link ?? "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
@@ -46,21 +85,10 @@ export const retrieveGitHubUserRepositories = async (
|
||||
|
||||
/**
|
||||
* Given a GitHub token, retrieves the authenticated user
|
||||
* @param token The GitHub token
|
||||
* @returns The authenticated user or an error response
|
||||
*/
|
||||
export const retrieveGitHubUser = async () => {
|
||||
const response = await github.get<GitHubUser>("/user", {
|
||||
transformResponse: (data) => {
|
||||
const parsedData: GitHubUser | GitHubErrorReponse = JSON.parse(data);
|
||||
|
||||
if (isGitHubErrorReponse(parsedData)) {
|
||||
throw new Error(parsedData.message);
|
||||
}
|
||||
|
||||
return parsedData;
|
||||
},
|
||||
});
|
||||
const response = await github.get<GitHubUser>("/user");
|
||||
|
||||
const { data } = response;
|
||||
|
||||
@@ -79,24 +107,14 @@ export const retrieveGitHubUser = async () => {
|
||||
export const retrieveLatestGitHubCommit = async (
|
||||
repository: string,
|
||||
): Promise<GitHubCommit> => {
|
||||
const response = await github.get<GitHubCommit>(
|
||||
const response = await github.get<GitHubCommit[]>(
|
||||
`/repos/${repository}/commits`,
|
||||
{
|
||||
params: {
|
||||
per_page: 1,
|
||||
},
|
||||
transformResponse: (data) => {
|
||||
const parsedData: GitHubCommit[] | GitHubErrorReponse =
|
||||
JSON.parse(data);
|
||||
|
||||
if (isGitHubErrorReponse(parsedData)) {
|
||||
throw new Error(parsedData.message);
|
||||
}
|
||||
|
||||
return parsedData[0];
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return response.data[0];
|
||||
};
|
||||
|
||||
@@ -42,7 +42,9 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async getConfig(): Promise<GetConfigResponse> {
|
||||
const { data } = await openHands.get<GetConfigResponse>("/config.json");
|
||||
const { data } = await openHands.get<GetConfigResponse>(
|
||||
"/api/options/config",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -51,8 +53,12 @@ class OpenHands {
|
||||
* @param path Path to list files from
|
||||
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
|
||||
*/
|
||||
static async getFiles(path?: string): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/api/list-files", {
|
||||
static async getFiles(
|
||||
conversationId: string,
|
||||
path?: string,
|
||||
): Promise<string[]> {
|
||||
const url = `/api/conversations/${conversationId}/list-files`;
|
||||
const { data } = await openHands.get<string[]>(url, {
|
||||
params: { path },
|
||||
});
|
||||
return data;
|
||||
@@ -63,8 +69,9 @@ class OpenHands {
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Content of the file
|
||||
*/
|
||||
static async getFile(path: string): Promise<string> {
|
||||
const { data } = await openHands.get<{ code: string }>("/api/select-file", {
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `/api/conversations/${conversationId}/select-file`;
|
||||
const { data } = await openHands.get<{ code: string }>(url, {
|
||||
params: { file: path },
|
||||
});
|
||||
|
||||
@@ -78,12 +85,14 @@ class OpenHands {
|
||||
* @returns Success message or error message
|
||||
*/
|
||||
static async saveFile(
|
||||
conversationId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<SaveFileSuccessResponse> {
|
||||
const url = `/api/conversations/${conversationId}/save-file`;
|
||||
const { data } = await openHands.post<
|
||||
SaveFileSuccessResponse | ErrorResponse
|
||||
>("/api/save-file", {
|
||||
>(url, {
|
||||
filePath: path,
|
||||
content,
|
||||
});
|
||||
@@ -97,13 +106,17 @@ class OpenHands {
|
||||
* @param file File to upload
|
||||
* @returns Success message or error message
|
||||
*/
|
||||
static async uploadFiles(files: File[]): Promise<FileUploadSuccessResponse> {
|
||||
static async uploadFiles(
|
||||
conversationId: string,
|
||||
files: File[],
|
||||
): Promise<FileUploadSuccessResponse> {
|
||||
const url = `/api/conversations/${conversationId}/upload-files`;
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file));
|
||||
|
||||
const { data } = await openHands.post<
|
||||
FileUploadSuccessResponse | ErrorResponse
|
||||
>("/api/upload-files", formData);
|
||||
>(url, formData);
|
||||
|
||||
if ("error" in data) throw new Error(data.error);
|
||||
return data;
|
||||
@@ -114,11 +127,12 @@ class OpenHands {
|
||||
* @param data Feedback data
|
||||
* @returns The stored feedback data
|
||||
*/
|
||||
static async submitFeedback(feedback: Feedback): Promise<FeedbackResponse> {
|
||||
const { data } = await openHands.post<FeedbackResponse>(
|
||||
"/api/submit-feedback",
|
||||
feedback,
|
||||
);
|
||||
static async submitFeedback(
|
||||
conversationId: string,
|
||||
feedback: Feedback,
|
||||
): Promise<FeedbackResponse> {
|
||||
const url = `/api/conversations/${conversationId}/submit-feedback`;
|
||||
const { data } = await openHands.post<FeedbackResponse>(url, feedback);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -136,12 +150,32 @@ class OpenHands {
|
||||
return response.status === 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Github Token
|
||||
* @returns Refreshed Github access token
|
||||
*/
|
||||
static async refreshToken(
|
||||
appMode: GetConfigResponse["APP_MODE"],
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
if (appMode === "oss") return "";
|
||||
|
||||
const response = await openHands.post<GitHubAccessTokenResponse>(
|
||||
"/api/refresh-token",
|
||||
{
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return response.data.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob of the workspace zip
|
||||
* @returns Blob of the workspace zip
|
||||
*/
|
||||
static async getWorkspaceZip(): Promise<Blob> {
|
||||
const response = await openHands.get("/api/zip-directory", {
|
||||
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
|
||||
const url = `/api/conversations/${conversationId}/zip-directory`;
|
||||
const response = await openHands.get(url, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
@@ -167,18 +201,67 @@ class OpenHands {
|
||||
* Get the VSCode URL
|
||||
* @returns VSCode URL
|
||||
*/
|
||||
static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
|
||||
const { data } =
|
||||
await openHands.get<GetVSCodeUrlResponse>("/api/vscode-url");
|
||||
static async getVSCodeUrl(
|
||||
conversationId: string,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const { data } = await openHands.get<GetVSCodeUrlResponse>(
|
||||
`/api/conversations/${conversationId}/vscode-url`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getRuntimeId(): Promise<{ runtime_id: string }> {
|
||||
static async getRuntimeId(
|
||||
conversationId: string,
|
||||
): Promise<{ runtime_id: string }> {
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(
|
||||
"/api/conversation",
|
||||
`/api/conversations/${conversationId}/config`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchEvents(
|
||||
conversationId: string,
|
||||
params: {
|
||||
query?: string;
|
||||
startId?: number;
|
||||
limit?: number;
|
||||
eventType?: string;
|
||||
source?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
},
|
||||
): Promise<{ events: Record<string, unknown>[]; has_more: boolean }> {
|
||||
const { data } = await openHands.get<{
|
||||
events: Record<string, unknown>[];
|
||||
has_more: boolean;
|
||||
}>(`/api/conversations/${conversationId}/events/search`, {
|
||||
params: {
|
||||
query: params.query,
|
||||
start_id: params.startId,
|
||||
limit: params.limit,
|
||||
event_type: params.eventType,
|
||||
source: params.source,
|
||||
start_date: params.startDate,
|
||||
end_date: params.endDate,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
static async newConversation(params: {
|
||||
githubToken?: string;
|
||||
args?: Record<string, unknown>;
|
||||
selectedRepository?: string;
|
||||
}): Promise<{ conversation_id: string }> {
|
||||
const { data } = await openHands.post<{
|
||||
conversation_id: string;
|
||||
}>("/api/conversations", {
|
||||
github_token: params.githubToken,
|
||||
args: params.args,
|
||||
selected_repository: params.selectedRepository,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface Feedback {
|
||||
|
||||
export interface GetConfigResponse {
|
||||
APP_MODE: "saas" | "oss";
|
||||
APP_SLUG?: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AgentState from "#/types/agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
enum IndicatorColor {
|
||||
BLUE = "bg-blue-500",
|
||||
|
||||
@@ -2,7 +2,7 @@ import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { downloadWorkspace } from "#/utils/download-workspace";
|
||||
import { DownloadModal } from "#/components/shared/download-modal";
|
||||
|
||||
interface ActionSuggestionsProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -16,19 +16,17 @@ export function ActionSuggestions({
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
const [hasPullRequest, setHasPullRequest] = React.useState(false);
|
||||
|
||||
const handleDownloadWorkspace = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadWorkspace();
|
||||
} catch (error) {
|
||||
// TODO: Handle error
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
const handleDownloadClose = () => {
|
||||
setIsDownloading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
<DownloadModal
|
||||
initialPath=""
|
||||
onClose={handleDownloadClose}
|
||||
isOpen={isDownloading}
|
||||
/>
|
||||
{gitHubToken ? (
|
||||
<div className="flex flex-row gap-2 justify-center w-full">
|
||||
{!hasPullRequest ? (
|
||||
@@ -75,13 +73,15 @@ export function ActionSuggestions({
|
||||
<SuggestionItem
|
||||
suggestion={{
|
||||
label: !isDownloading
|
||||
? "Download .zip"
|
||||
? "Download files"
|
||||
: "Downloading, please wait...",
|
||||
value: "Download .zip",
|
||||
value: "Download files",
|
||||
}}
|
||||
onClick={() => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
handleDownloadWorkspace();
|
||||
if (!isDownloading) {
|
||||
setIsDownloading(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { addUserMessage } from "#/state/chat-slice";
|
||||
import { RootState } from "#/store";
|
||||
import AgentState from "#/types/agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { code } from "../markdown/code";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ul, ol } from "../markdown/list";
|
||||
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
|
||||
import { anchor } from "../markdown/anchor";
|
||||
|
||||
interface ChatMessageProps {
|
||||
type: "user" | "assistant";
|
||||
@@ -62,6 +63,7 @@ export function ChatMessage({
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import ArrowUp from "#/icons/angle-up-solid.svg?react";
|
||||
import ArrowDown from "#/icons/angle-down-solid.svg?react";
|
||||
import CheckCircle from "#/icons/check-circle-solid.svg?react";
|
||||
import XCircle from "#/icons/x-circle-solid.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ExpandableMessageProps {
|
||||
id?: string;
|
||||
@@ -35,27 +36,63 @@ export function ExpandableMessage({
|
||||
}
|
||||
}, [id, message, i18n.language]);
|
||||
|
||||
const arrowClasses = "h-4 w-4 ml-2 inline fill-neutral-300";
|
||||
const statusIconClasses = "h-4 w-4 ml-2 inline";
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center justify-between border-l-2 border-neutral-300 pl-2 my-2 py-2">
|
||||
<div className="text-sm leading-4 flex flex-col gap-2 max-w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 items-center justify-start border-l-2 pl-2 my-2 py-2",
|
||||
type === "error" ? "border-danger" : "border-neutral-300",
|
||||
)}
|
||||
>
|
||||
<div className="text-sm w-full">
|
||||
{headline && (
|
||||
<p className="text-neutral-300 font-bold">
|
||||
{headline}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="cursor-pointer text-left"
|
||||
>
|
||||
{showDetails ? (
|
||||
<ArrowUp className={arrowClasses} />
|
||||
) : (
|
||||
<ArrowDown className={arrowClasses} />
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold",
|
||||
type === "error" ? "text-danger" : "text-neutral-300",
|
||||
)}
|
||||
</button>
|
||||
</p>
|
||||
>
|
||||
{headline}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="cursor-pointer text-left"
|
||||
>
|
||||
{showDetails ? (
|
||||
<ArrowUp
|
||||
className={cn(
|
||||
"h-4 w-4 ml-2 inline",
|
||||
type === "error" ? "fill-danger" : "fill-neutral-300",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ArrowDown
|
||||
className={cn(
|
||||
"h-4 w-4 ml-2 inline",
|
||||
type === "error" ? "fill-danger" : "fill-neutral-300",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
{type === "action" && success !== undefined && (
|
||||
<span className="flex-shrink-0">
|
||||
{success ? (
|
||||
<CheckCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-success")}
|
||||
/>
|
||||
) : (
|
||||
<XCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-danger")}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showDetails && (
|
||||
<Markdown
|
||||
@@ -71,21 +108,6 @@ export function ExpandableMessage({
|
||||
</Markdown>
|
||||
)}
|
||||
</div>
|
||||
{type === "action" && success !== undefined && (
|
||||
<div className="flex-shrink-0">
|
||||
{success ? (
|
||||
<CheckCircle
|
||||
data-testid="status-icon"
|
||||
className={`${statusIconClasses} fill-success`}
|
||||
/>
|
||||
) : (
|
||||
<XCircle
|
||||
data-testid="status-icon"
|
||||
className={`${statusIconClasses} fill-danger`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import PauseIcon from "#/assets/pause";
|
||||
import PlayIcon from "#/assets/play";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { RootState } from "#/store";
|
||||
import AgentState from "#/types/agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { IGNORE_TASK_STATE_MAP } from "#/ignore-task-state-map.constant";
|
||||
import { ActionButton } from "#/components/shared/buttons/action-button";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import { RootState } from "#/store";
|
||||
import AgentState from "#/types/agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { AGENT_STATUS_MAP } from "../../agent-status-map.constant";
|
||||
|
||||
export function AgentStatusBar() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AgentState from "#/types/agent-state";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
|
||||
import toast from "#/utils/toast";
|
||||
import { RootState } from "#/store";
|
||||
@@ -38,7 +38,7 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
|
||||
const { data: paths, refetch, error } = useListFiles();
|
||||
const { mutate: uploadFiles } = useUploadFiles();
|
||||
const { data: vscodeUrl } = useVSCodeUrl({
|
||||
enabled: status === WsClientProviderStatus.ACTIVE,
|
||||
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
|
||||
});
|
||||
|
||||
const handleOpenVSCode = () => {
|
||||
@@ -49,12 +49,13 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
|
||||
),
|
||||
);
|
||||
window.open(vscodeUrl.vscode_url, "_blank");
|
||||
} else if (vscodeUrl?.error) {
|
||||
} else {
|
||||
const errorMessage = vscodeUrl?.error || t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
|
||||
error: "VSCode server is not ready. Please try again in a few seconds.",
|
||||
});
|
||||
toast.error(
|
||||
`open-vscode-error-${new Date().getTime()}`,
|
||||
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
|
||||
error: vscodeUrl.error,
|
||||
}),
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -96,10 +97,7 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
|
||||
};
|
||||
|
||||
const refreshWorkspace = () => {
|
||||
if (
|
||||
curAgentState !== AgentState.LOADING &&
|
||||
curAgentState !== AgentState.STOPPED
|
||||
) {
|
||||
if (!RUNTIME_INACTIVE_STATES.includes(curAgentState)) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
@@ -170,7 +168,7 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
|
||||
{isOpen && (
|
||||
<OpenVSCodeButton
|
||||
onClick={handleOpenVSCode}
|
||||
isDisabled={status === WsClientProviderStatus.OPENING}
|
||||
isDisabled={status === WsClientProviderStatus.DISCONNECTED}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { setSelectedRepository } from "#/state/initial-query-slice";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
interface GitHubRepositorySelectorProps {
|
||||
onSelect: () => void;
|
||||
@@ -12,15 +14,31 @@ export function GitHubRepositorySelector({
|
||||
onSelect,
|
||||
repositories,
|
||||
}: GitHubRepositorySelectorProps) {
|
||||
const { data: config } = useConfig();
|
||||
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
|
||||
|
||||
// Add option to install app onto more repos
|
||||
const finalRepositories =
|
||||
config?.APP_MODE === "saas"
|
||||
? [{ id: -1000, full_name: "Add more repositories..." }, ...repositories]
|
||||
: repositories;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleRepoSelection = (id: string | null) => {
|
||||
const repo = repositories.find((r) => r.id.toString() === id);
|
||||
if (repo) {
|
||||
const repo = finalRepositories.find((r) => r.id.toString() === id);
|
||||
if (id === "-1000") {
|
||||
if (config?.APP_SLUG)
|
||||
window.open(
|
||||
`https://github.com/apps/${config.APP_SLUG}/installations/new`,
|
||||
"_blank",
|
||||
);
|
||||
} else if (repo) {
|
||||
// set query param
|
||||
dispatch(setSelectedRepository(repo.full_name));
|
||||
posthog.capture("repository_selected");
|
||||
onSelect();
|
||||
setSelectedKey(id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,12 +47,26 @@ export function GitHubRepositorySelector({
|
||||
dispatch(setSelectedRepository(null));
|
||||
};
|
||||
|
||||
const emptyContent = config?.APP_SLUG ? (
|
||||
<a
|
||||
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline"
|
||||
>
|
||||
Add more repositories...
|
||||
</a>
|
||||
) : (
|
||||
"No results found."
|
||||
);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
data-testid="github-repo-selector"
|
||||
name="repo"
|
||||
aria-label="GitHub Repository"
|
||||
placeholder="Select a GitHub project"
|
||||
selectedKey={selectedKey}
|
||||
inputProps={{
|
||||
classNames: {
|
||||
inputWrapper:
|
||||
@@ -43,8 +75,11 @@ export function GitHubRepositorySelector({
|
||||
}}
|
||||
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
|
||||
clearButtonProps={{ onClick: handleClearSelection }}
|
||||
listboxProps={{
|
||||
emptyContent,
|
||||
}}
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
{finalRepositories.map((repo) => (
|
||||
<AutocompleteItem
|
||||
data-testid="github-repo-item"
|
||||
key={repo.id}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from "react";
|
||||
import { isGitHubErrorReponse } from "#/api/github";
|
||||
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import { GitHubRepositorySelector } from "./github-repo-selector";
|
||||
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
|
||||
|
||||
interface GitHubRepositoriesSuggestionBoxProps {
|
||||
handleSubmit: () => void;
|
||||
|
||||
20
frontend/src/components/features/markdown/anchor.tsx
Normal file
20
frontend/src/components/features/markdown/anchor.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import { ExtraProps } from "react-markdown";
|
||||
|
||||
export function anchor({
|
||||
href,
|
||||
children,
|
||||
}: React.ClassAttributes<HTMLAnchorElement> &
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement> &
|
||||
ExtraProps) {
|
||||
return (
|
||||
<a
|
||||
className="text-blue-500 hover:underline"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import posthog from "posthog-js";
|
||||
import EllipsisH from "#/icons/ellipsis-h.svg?react";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
|
||||
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
|
||||
import { ProjectMenuDetails } from "./project-menu-details";
|
||||
import { downloadWorkspace } from "#/utils/download-workspace";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { DownloadModal } from "#/components/shared/download-modal";
|
||||
|
||||
interface ProjectMenuCardProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -30,7 +28,7 @@ export function ProjectMenuCard({
|
||||
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [working, setWorking] = React.useState(false);
|
||||
const [downloading, setDownloading] = React.useState(false);
|
||||
|
||||
const toggleMenuVisibility = () => {
|
||||
setContextMenuIsOpen((prev) => !prev);
|
||||
@@ -58,20 +56,16 @@ Please push the changes to GitHub and open a pull request.
|
||||
|
||||
const handleDownloadWorkspace = () => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
try {
|
||||
setWorking(true);
|
||||
downloadWorkspace().then(
|
||||
() => setWorking(false),
|
||||
() => setWorking(false),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
setDownloading(true);
|
||||
};
|
||||
|
||||
const handleDownloadClose = () => {
|
||||
setDownloading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
|
||||
{!working && contextMenuIsOpen && (
|
||||
{!downloading && contextMenuIsOpen && (
|
||||
<ProjectMenuCardContextMenu
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
@@ -93,17 +87,20 @@ Please push the changes to GitHub and open a pull request.
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMenuVisibility}
|
||||
aria-label="Open project menu"
|
||||
>
|
||||
{working ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
<DownloadModal
|
||||
initialPath=""
|
||||
onClose={handleDownloadClose}
|
||||
isOpen={downloading}
|
||||
/>
|
||||
{!downloading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMenuVisibility}
|
||||
aria-label="Open project menu"
|
||||
>
|
||||
<EllipsisH width={36} height={36} />
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
)}
|
||||
{connectToGitHubModalOpen && (
|
||||
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
|
||||
<ConnectToGitHubModal
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ProjectMenuCardContextMenu({
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
<ContextMenuListItem onClick={onDownloadWorkspace}>
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL)}
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Sidebar() {
|
||||
const user = useGitHubUser();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
|
||||
const { token, logout } = useAuth();
|
||||
const { logout } = useAuth();
|
||||
const { settingsAreUpToDate } = useUserPrefs();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
@@ -45,7 +45,7 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
const handleClickLogo = () => {
|
||||
if (location.pathname.startsWith("/app"))
|
||||
if (location.pathname.startsWith("/conversations/"))
|
||||
setStartNewProjectModalIsOpen(true);
|
||||
};
|
||||
|
||||
@@ -68,11 +68,9 @@ export function Sidebar() {
|
||||
/>
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
<DocsButton />
|
||||
{!!token && (
|
||||
<ExitProjectButton
|
||||
onClick={() => setStartNewProjectModalIsOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ExitProjectButton
|
||||
onClick={() => setStartNewProjectModalIsOpen(true)}
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
{accountSettingsModalOpen && (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
export function TerminalStatusLabel() {
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
curAgentState === AgentState.LOADING ||
|
||||
curAgentState === AgentState.STOPPED
|
||||
? "bg-red-500 animate-pulse"
|
||||
: "bg-green-500",
|
||||
)}
|
||||
/>
|
||||
Terminal
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import { useTerminal } from "#/hooks/use-terminal";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
|
||||
interface TerminalProps {
|
||||
secrets: string[];
|
||||
@@ -10,7 +10,13 @@ interface TerminalProps {
|
||||
|
||||
function Terminal({ secrets }: TerminalProps) {
|
||||
const { commands } = useSelector((state: RootState) => state.cmd);
|
||||
const ref = useTerminal(commands, secrets);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const ref = useTerminal({
|
||||
commands,
|
||||
secrets,
|
||||
disabled: RUNTIME_INACTIVE_STATES.includes(curAgentState),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full p-2 min-h-0">
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { NavTab } from "./nav-tab";
|
||||
|
||||
interface ContainerProps {
|
||||
label?: string;
|
||||
label?: React.ReactNode;
|
||||
labels?: {
|
||||
label: string | React.ReactNode;
|
||||
to: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import AgentState from "#/types/agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
interface ActionButtonProps {
|
||||
isDisabled?: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AgentState from "#/types/agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { ActionTooltip } from "../action-tooltip";
|
||||
|
||||
33
frontend/src/components/shared/download-modal.tsx
Normal file
33
frontend/src/components/shared/download-modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useDownloadProgress } from "#/hooks/use-download-progress";
|
||||
import { DownloadProgress } from "./download-progress";
|
||||
|
||||
interface DownloadModalProps {
|
||||
initialPath: string;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function ActiveDownload({
|
||||
initialPath,
|
||||
onClose,
|
||||
}: {
|
||||
initialPath: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { progress, cancelDownload } = useDownloadProgress(
|
||||
initialPath,
|
||||
onClose,
|
||||
);
|
||||
|
||||
return <DownloadProgress progress={progress} onCancel={cancelDownload} />;
|
||||
}
|
||||
|
||||
export function DownloadModal({
|
||||
initialPath,
|
||||
onClose,
|
||||
isOpen,
|
||||
}: DownloadModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return <ActiveDownload initialPath={initialPath} onClose={onClose} />;
|
||||
}
|
||||
87
frontend/src/components/shared/download-progress.tsx
Normal file
87
frontend/src/components/shared/download-progress.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
export interface DownloadProgressState {
|
||||
filesTotal: number;
|
||||
filesDownloaded: number;
|
||||
currentFile: string;
|
||||
totalBytesDownloaded: number;
|
||||
bytesDownloadedPerSecond: number;
|
||||
isDiscoveringFiles: boolean;
|
||||
}
|
||||
|
||||
interface DownloadProgressProps {
|
||||
progress: DownloadProgressState;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgress({
|
||||
progress,
|
||||
onCancel,
|
||||
}: DownloadProgressProps) {
|
||||
const formatBytes = (bytes: number) => {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-20">
|
||||
<div className="bg-[#1C1C1C] rounded-lg p-6 max-w-md w-full mx-4 border border-[#525252]">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold mb-2 text-white">
|
||||
{progress.isDiscoveringFiles
|
||||
? "Preparing Download..."
|
||||
: "Downloading Files"}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 truncate">
|
||||
{progress.isDiscoveringFiles
|
||||
? `Found ${progress.filesTotal} files...`
|
||||
: progress.currentFile}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="h-2 bg-[#2C2C2C] rounded-full overflow-hidden">
|
||||
{progress.isDiscoveringFiles ? (
|
||||
<div
|
||||
className="h-full bg-blue-500 animate-pulse"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{
|
||||
width: `${(progress.filesDownloaded / progress.filesTotal) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm text-gray-400">
|
||||
<span>
|
||||
{progress.isDiscoveringFiles
|
||||
? `Scanning workspace...`
|
||||
: `${progress.filesDownloaded} of ${progress.filesTotal} files`}
|
||||
</span>
|
||||
{!progress.isDiscoveringFiles && (
|
||||
<span>{formatBytes(progress.bytesDownloadedPerSecond)}/s</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
import { CustomInput } from "../../custom-input";
|
||||
import { FormFieldset } from "../../form-fieldset";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
interface AccountSettingsFormProps {
|
||||
onClose: () => void;
|
||||
@@ -28,6 +29,7 @@ export function AccountSettingsForm({
|
||||
analyticsConsent,
|
||||
}: AccountSettingsFormProps) {
|
||||
const { gitHubToken, setGitHubToken, logout } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { saveSettings } = useUserPrefs();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -64,6 +66,16 @@ export function AccountSettingsForm({
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<BaseModalTitle title="Account Settings" />
|
||||
|
||||
{config?.APP_MODE === "saas" && config?.APP_SLUG && (
|
||||
<a
|
||||
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline"
|
||||
>
|
||||
Configure Github Repositories
|
||||
</a>
|
||||
)}
|
||||
<FormFieldset
|
||||
id="language"
|
||||
label="Language"
|
||||
@@ -75,23 +87,27 @@ export function AccountSettingsForm({
|
||||
}))}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
name="ghToken"
|
||||
label="GitHub Token"
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
<>
|
||||
<CustomInput
|
||||
name="ghToken"
|
||||
label="GitHub Token"
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
</>
|
||||
)}
|
||||
{gitHubError && (
|
||||
<p className="text-danger text-xs">
|
||||
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import AgentState from "#/types/agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { DangerModal } from "./confirmation-modals/danger-modal";
|
||||
import { ModalBackdrop } from "./modal-backdrop";
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-10">
|
||||
<div className="fixed inset-0 flex items-center justify-center z-20">
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="fixed inset-0 bg-black bg-opacity-80"
|
||||
|
||||
@@ -24,6 +24,7 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
|
||||
import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@@ -44,6 +45,7 @@ export function SettingsForm({
|
||||
}: SettingsFormProps) {
|
||||
const { saveSettings } = useUserPrefs();
|
||||
const endSession = useEndSession();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
@@ -85,7 +87,7 @@ export function SettingsForm({
|
||||
const [showWarningModal, setShowWarningModal] = React.useState(false);
|
||||
|
||||
const resetOngoingSession = () => {
|
||||
if (location.pathname.startsWith("/app")) {
|
||||
if (location.pathname.startsWith("/conversations/")) {
|
||||
endSession();
|
||||
onClose();
|
||||
}
|
||||
@@ -96,9 +98,9 @@ export function SettingsForm({
|
||||
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
|
||||
const newSettings = extractSettings(formData);
|
||||
|
||||
saveSettings(newSettings);
|
||||
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
|
||||
updateSettingsVersion();
|
||||
updateSettingsVersion(logout);
|
||||
saveSettings(newSettings);
|
||||
resetOngoingSession();
|
||||
|
||||
posthog.capture("settings_saved", {
|
||||
@@ -127,7 +129,7 @@ export function SettingsForm({
|
||||
|
||||
if (!apiKey) {
|
||||
setShowWarningModal(true);
|
||||
} else if (location.pathname.startsWith("/app")) {
|
||||
} else if (location.pathname.startsWith("/conversations/")) {
|
||||
setConfirmEndSessionModalOpen(true);
|
||||
} else {
|
||||
handleFormSubmission(formData);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useNavigation } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import { RootState } from "#/store";
|
||||
import {
|
||||
@@ -8,6 +9,10 @@ import {
|
||||
removeFile,
|
||||
setInitialQuery,
|
||||
} from "#/state/initial-query-slice";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useUserPrefs } from "#/context/user-prefs-context";
|
||||
|
||||
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
@@ -22,6 +27,8 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { settings } = useUserPrefs();
|
||||
|
||||
const { selectedRepository, files } = useSelector(
|
||||
(state: RootState) => state.initalQuery,
|
||||
@@ -32,6 +39,25 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
getRandomKey(SUGGESTIONS["non-repo"]),
|
||||
);
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
const newConversationMutation = useMutation({
|
||||
mutationFn: (variables: { q?: string }) => {
|
||||
if (variables.q) dispatch(setInitialQuery(variables.q));
|
||||
return OpenHands.newConversation({
|
||||
githubToken: gitHubToken || undefined,
|
||||
selectedRepository: selectedRepository || undefined,
|
||||
args: settings || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: ({ conversation_id: conversationId }, { q }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
});
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const onRefreshSuggestion = () => {
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
@@ -62,16 +88,7 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const q = formData.get("q")?.toString();
|
||||
if (q) dispatch(setInitialQuery(q));
|
||||
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
});
|
||||
|
||||
navigate("/app");
|
||||
newConversationMutation.mutate({ q });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -114,7 +131,10 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
showButton={!!text}
|
||||
className="text-[17px] leading-5 py-[17px]"
|
||||
buttonClassName="pb-[17px]"
|
||||
disabled={navigation.state === "submitting"}
|
||||
disabled={
|
||||
navigation.state === "submitting" ||
|
||||
newConversationMutation.isPending
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,62 +1,46 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
removeAuthTokenHeader as removeOpenHandsAuthTokenHeader,
|
||||
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
|
||||
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
|
||||
setAuthTokenHeader as setOpenHandsAuthTokenHeader,
|
||||
} from "#/api/open-hands-axios";
|
||||
import {
|
||||
setAuthTokenHeader as setGitHubAuthTokenHeader,
|
||||
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
|
||||
setupAxiosInterceptors as setupGithubAxiosInterceptors,
|
||||
} from "#/api/github-axios-instance";
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
gitHubToken: string | null;
|
||||
setToken: (token: string | null) => void;
|
||||
setUserId: (userId: string) => void;
|
||||
setGitHubToken: (token: string | null) => void;
|
||||
clearToken: () => void;
|
||||
clearGitHubToken: () => void;
|
||||
refreshToken: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
const [tokenState, setTokenState] = React.useState<string | null>(() =>
|
||||
localStorage.getItem("token"),
|
||||
);
|
||||
const [gitHubTokenState, setGitHubTokenState] = React.useState<string | null>(
|
||||
() => localStorage.getItem("ghToken"),
|
||||
);
|
||||
|
||||
const clearToken = () => {
|
||||
setTokenState(null);
|
||||
localStorage.removeItem("token");
|
||||
|
||||
removeOpenHandsAuthTokenHeader();
|
||||
};
|
||||
const [userIdState, setUserIdState] = React.useState<string>(
|
||||
() => localStorage.getItem("userId") || "",
|
||||
);
|
||||
|
||||
const clearGitHubToken = () => {
|
||||
setGitHubTokenState(null);
|
||||
setUserIdState("");
|
||||
localStorage.removeItem("ghToken");
|
||||
localStorage.removeItem("userId");
|
||||
|
||||
removeOpenHandsGitHubTokenHeader();
|
||||
removeGitHubAuthTokenHeader();
|
||||
};
|
||||
|
||||
const setToken = (token: string | null) => {
|
||||
setTokenState(token);
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("token", token);
|
||||
setOpenHandsAuthTokenHeader(token);
|
||||
} else {
|
||||
clearToken();
|
||||
}
|
||||
};
|
||||
|
||||
const setGitHubToken = (token: string | null) => {
|
||||
setGitHubTokenState(token);
|
||||
|
||||
@@ -69,30 +53,53 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const storedToken = localStorage.getItem("token");
|
||||
const storedGitHubToken = localStorage.getItem("ghToken");
|
||||
|
||||
setToken(storedToken);
|
||||
setGitHubToken(storedGitHubToken);
|
||||
}, []);
|
||||
const setUserId = (userId: string) => {
|
||||
setUserIdState(userIdState);
|
||||
localStorage.setItem("userId", userId);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearGitHubToken();
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
const refreshToken = async (): Promise<boolean> => {
|
||||
const config = await OpenHands.getConfig();
|
||||
|
||||
if (config.APP_MODE !== "saas" || !gitHubTokenState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newToken = await OpenHands.refreshToken(config.APP_MODE, userIdState);
|
||||
if (newToken) {
|
||||
setGitHubToken(newToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearGitHubToken();
|
||||
return false;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const storedGitHubToken = localStorage.getItem("ghToken");
|
||||
|
||||
const userId = localStorage.getItem("userId") || "";
|
||||
|
||||
setGitHubToken(storedGitHubToken);
|
||||
setUserId(userId);
|
||||
setupGithubAxiosInterceptors(refreshToken, logout);
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
token: tokenState,
|
||||
gitHubToken: gitHubTokenState,
|
||||
setToken,
|
||||
setGitHubToken,
|
||||
clearToken,
|
||||
setUserId,
|
||||
clearGitHubToken,
|
||||
refreshToken,
|
||||
logout,
|
||||
}),
|
||||
[tokenState, gitHubTokenState],
|
||||
[gitHubTokenState],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
42
frontend/src/context/conversation-context.tsx
Normal file
42
frontend/src/context/conversation-context.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
interface ConversationContextType {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
const ConversationContext = React.createContext<ConversationContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function ConversationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error(
|
||||
"ConversationProvider must be used within a route that has a conversationId parameter",
|
||||
);
|
||||
}
|
||||
|
||||
const value = useMemo(() => ({ conversationId }), [conversationId]);
|
||||
|
||||
return (
|
||||
<ConversationContext.Provider value={value}>
|
||||
{children}
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConversation() {
|
||||
const context = React.useContext(ConversationContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useConversation must be used within a ConversationProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { Settings } from "#/services/settings";
|
||||
import ActionType from "#/types/action-type";
|
||||
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import { useRate } from "#/hooks/use-rate";
|
||||
import AgentState from "#/types/agent-state";
|
||||
|
||||
const isOpenHandsMessage = (event: Record<string, unknown>) =>
|
||||
event.action === "message";
|
||||
|
||||
export enum WsClientProviderStatus {
|
||||
STOPPED,
|
||||
OPENING,
|
||||
ACTIVE,
|
||||
ERROR,
|
||||
CONNECTED,
|
||||
DISCONNECTED,
|
||||
}
|
||||
|
||||
interface UseWsClient {
|
||||
@@ -26,7 +22,7 @@ interface UseWsClient {
|
||||
}
|
||||
|
||||
const WsClientContext = React.createContext<UseWsClient>({
|
||||
status: WsClientProviderStatus.STOPPED,
|
||||
status: WsClientProviderStatus.DISCONNECTED,
|
||||
isLoadingMessages: true,
|
||||
events: [],
|
||||
send: () => {
|
||||
@@ -35,29 +31,23 @@ const WsClientContext = React.createContext<UseWsClient>({
|
||||
});
|
||||
|
||||
interface WsClientProviderProps {
|
||||
enabled: boolean;
|
||||
token: string | null;
|
||||
conversationId: string;
|
||||
ghToken: string | null;
|
||||
selectedRepository: string | null;
|
||||
settings: Settings | null;
|
||||
}
|
||||
|
||||
export function WsClientProvider({
|
||||
enabled,
|
||||
token,
|
||||
ghToken,
|
||||
selectedRepository,
|
||||
settings,
|
||||
conversationId,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const tokenRef = React.useRef<string | null>(token);
|
||||
const ghTokenRef = React.useRef<string | null>(ghToken);
|
||||
const selectedRepositoryRef = React.useRef<string | null>(selectedRepository);
|
||||
const disconnectRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const [status, setStatus] = React.useState(WsClientProviderStatus.STOPPED);
|
||||
const [status, setStatus] = React.useState(
|
||||
WsClientProviderStatus.DISCONNECTED,
|
||||
);
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
|
||||
|
||||
@@ -72,26 +62,7 @@ export function WsClientProvider({
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
setStatus(WsClientProviderStatus.OPENING);
|
||||
|
||||
const initEvent: Record<string, unknown> = {
|
||||
action: ActionType.INIT,
|
||||
args: settings,
|
||||
};
|
||||
if (token) {
|
||||
initEvent.token = token;
|
||||
}
|
||||
if (ghToken) {
|
||||
initEvent.github_token = ghToken;
|
||||
}
|
||||
if (selectedRepository) {
|
||||
initEvent.selected_repository = selectedRepository;
|
||||
}
|
||||
const lastEvent = lastEventRef.current;
|
||||
if (lastEvent) {
|
||||
initEvent.latest_event_id = lastEvent.id;
|
||||
}
|
||||
send(initEvent);
|
||||
setStatus(WsClientProviderStatus.CONNECTED);
|
||||
}
|
||||
|
||||
function handleMessage(event: Record<string, unknown>) {
|
||||
@@ -103,60 +74,41 @@ export function WsClientProvider({
|
||||
lastEventRef.current = event;
|
||||
}
|
||||
|
||||
const extras = event.extras as Record<string, unknown>;
|
||||
if (extras?.agent_state === AgentState.INIT) {
|
||||
setStatus(WsClientProviderStatus.ACTIVE);
|
||||
}
|
||||
|
||||
if (
|
||||
status !== WsClientProviderStatus.ACTIVE &&
|
||||
event?.observation === "error"
|
||||
) {
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.token) {
|
||||
handleAssistantMessage(event);
|
||||
}
|
||||
handleAssistantMessage(event);
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
setStatus(WsClientProviderStatus.STOPPED);
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
}
|
||||
|
||||
function handleError() {
|
||||
posthog.capture("socket_error");
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
}
|
||||
|
||||
// Connect websocket
|
||||
React.useEffect(() => {
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
}
|
||||
|
||||
let sio = sioRef.current;
|
||||
|
||||
// If disabled disconnect any existing websockets...
|
||||
if (!enabled) {
|
||||
if (sio) {
|
||||
sio.disconnect();
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
const lastEvent = lastEventRef.current;
|
||||
const query = {
|
||||
latest_event_id: lastEvent?.id ?? -1,
|
||||
conversation_id: conversationId,
|
||||
};
|
||||
|
||||
// If there is no websocket or the tokens have changed or the current websocket is disconnected,
|
||||
// create a new one
|
||||
if (
|
||||
!sio ||
|
||||
(tokenRef.current && token && token !== tokenRef.current) ||
|
||||
ghToken !== ghTokenRef.current
|
||||
) {
|
||||
sio?.disconnect();
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
sio = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
});
|
||||
}
|
||||
sio = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
auth: {
|
||||
github_token: ghToken || undefined,
|
||||
},
|
||||
query,
|
||||
});
|
||||
sio.on("connect", handleConnect);
|
||||
sio.on("oh_event", handleMessage);
|
||||
sio.on("connect_error", handleError);
|
||||
@@ -164,9 +116,7 @@ export function WsClientProvider({
|
||||
sio.on("disconnect", handleDisconnect);
|
||||
|
||||
sioRef.current = sio;
|
||||
tokenRef.current = token;
|
||||
ghTokenRef.current = ghToken;
|
||||
selectedRepositoryRef.current = selectedRepository;
|
||||
|
||||
return () => {
|
||||
sio.off("connect", handleConnect);
|
||||
@@ -175,7 +125,7 @@ export function WsClientProvider({
|
||||
sio.off("connect_failed", handleError);
|
||||
sio.off("disconnect", handleDisconnect);
|
||||
};
|
||||
}, [enabled, token, ghToken, selectedRepository]);
|
||||
}, [ghToken, conversationId]);
|
||||
|
||||
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
|
||||
// before actually disconnecting the socket and cancel the operation if the component gets remounted.
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type SaveFileArgs = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const useSaveFile = () =>
|
||||
useMutation({
|
||||
export const useSaveFile = () => {
|
||||
const { conversationId } = useConversation();
|
||||
return useMutation({
|
||||
mutationFn: ({ path, content }: SaveFileArgs) =>
|
||||
OpenHands.saveFile(path, content),
|
||||
OpenHands.saveFile(conversationId, path, content),
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,16 +2,19 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import { Feedback } from "#/api/open-hands.types";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type SubmitFeedbackArgs = {
|
||||
feedback: Feedback;
|
||||
};
|
||||
|
||||
export const useSubmitFeedback = () =>
|
||||
useMutation({
|
||||
export const useSubmitFeedback = () => {
|
||||
const { conversationId } = useConversation();
|
||||
return useMutation({
|
||||
mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
|
||||
OpenHands.submitFeedback(feedback),
|
||||
OpenHands.submitFeedback(conversationId, feedback),
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type UploadFilesArgs = {
|
||||
files: File[];
|
||||
};
|
||||
|
||||
export const useUploadFiles = () =>
|
||||
useMutation({
|
||||
mutationFn: ({ files }: UploadFilesArgs) => OpenHands.uploadFiles(files),
|
||||
export const useUploadFiles = () => {
|
||||
const { conversationId } = useConversation();
|
||||
return useMutation({
|
||||
mutationFn: ({ files }: UploadFilesArgs) =>
|
||||
OpenHands.uploadFiles(conversationId, files),
|
||||
});
|
||||
};
|
||||
|
||||
21
frontend/src/hooks/query/use-app-installations.ts
Normal file
21
frontend/src/hooks/query/use-app-installations.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
import { retrieveGitHubAppInstallations } from "#/api/github";
|
||||
|
||||
export const useAppInstallations = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { gitHubToken } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", gitHubToken, config?.GITHUB_CLIENT_ID],
|
||||
queryFn: async () => {
|
||||
const data = await retrieveGitHubAppInstallations();
|
||||
return data;
|
||||
},
|
||||
enabled:
|
||||
!!gitHubToken &&
|
||||
!!config?.GITHUB_CLIENT_ID &&
|
||||
config?.APP_MODE === "saas",
|
||||
});
|
||||
};
|
||||
65
frontend/src/hooks/query/use-app-repositories.ts
Normal file
65
frontend/src/hooks/query/use-app-repositories.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { retrieveGitHubAppRepositories } from "#/api/github";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useAppInstallations } from "./use-app-installations";
|
||||
import { useConfig } from "./use-config";
|
||||
|
||||
export const useAppRepositories = () => {
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { data: installations } = useAppInstallations();
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", gitHubToken, installations],
|
||||
queryFn: async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: { installationIndex: number | null; repoPage: number | null };
|
||||
}) => {
|
||||
const { repoPage, installationIndex } = pageParam;
|
||||
|
||||
if (!installations) {
|
||||
throw new Error("Missing installation list");
|
||||
}
|
||||
|
||||
return retrieveGitHubAppRepositories(
|
||||
installationIndex || 0,
|
||||
installations,
|
||||
repoPage || 1,
|
||||
30,
|
||||
);
|
||||
},
|
||||
initialPageParam: { installationIndex: 0, repoPage: 1 },
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.nextPage) {
|
||||
return {
|
||||
installationIndex: lastPage.installationIndex,
|
||||
repoPage: lastPage.nextPage,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastPage.installationIndex !== null) {
|
||||
return { installationIndex: lastPage.installationIndex, repoPage: 1 };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
enabled:
|
||||
!!gitHubToken &&
|
||||
Array.isArray(installations) &&
|
||||
installations.length > 0 &&
|
||||
config?.APP_MODE === "saas",
|
||||
});
|
||||
|
||||
// TODO: Once we create our custom dropdown component, we should fetch data onEndReached
|
||||
// (nextui autocomplete doesn't support onEndReached nor is it compatible for extending)
|
||||
const { isSuccess, isFetchingNextPage, hasNextPage, fetchNextPage } = repos;
|
||||
React.useEffect(() => {
|
||||
if (!isFetchingNextPage && isSuccess && hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [isFetchingNextPage, isSuccess, hasNextPage, fetchNextPage]);
|
||||
|
||||
return repos;
|
||||
};
|
||||
@@ -4,15 +4,20 @@ import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useConversationConfig = () => {
|
||||
const { status } = useWsClient();
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["conversation_config"],
|
||||
queryFn: OpenHands.getRuntimeId,
|
||||
enabled: status === WsClientProviderStatus.ACTIVE,
|
||||
queryKey: ["conversation_config", conversationId],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return OpenHands.getRuntimeId(conversationId);
|
||||
},
|
||||
enabled: status === WsClientProviderStatus.CONNECTED && !!conversationId,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "./use-config";
|
||||
|
||||
export const useGitHubUser = () => {
|
||||
const { gitHubToken } = useAuth();
|
||||
const { gitHubToken, setUserId } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const user = useQuery({
|
||||
@@ -18,6 +18,7 @@ export const useGitHubUser = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user.data) {
|
||||
setUserId(user.data.id.toString());
|
||||
posthog.identify(user.data.login, {
|
||||
company: user.data.company,
|
||||
name: user.data.name,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
interface UseListFileConfig {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const useListFile = (config: UseListFileConfig) =>
|
||||
useQuery({
|
||||
queryKey: ["file", config.path],
|
||||
queryFn: () => OpenHands.getFile(config.path),
|
||||
export const useListFile = (config: UseListFileConfig) => {
|
||||
const { conversationId } = useConversation();
|
||||
return useQuery({
|
||||
queryKey: ["file", conversationId, config.path],
|
||||
queryFn: () => OpenHands.getFile(conversationId, config.path),
|
||||
enabled: false, // don't fetch by default, trigger manually via `refetch`
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
interface UseListFilesConfig {
|
||||
path?: string;
|
||||
@@ -12,13 +12,13 @@ interface UseListFilesConfig {
|
||||
}
|
||||
|
||||
export const useListFiles = (config?: UseListFilesConfig) => {
|
||||
const { token } = useAuth();
|
||||
const { conversationId } = useConversation();
|
||||
const { status } = useWsClient();
|
||||
const isActive = status === WsClientProviderStatus.ACTIVE;
|
||||
const isActive = status === WsClientProviderStatus.CONNECTED;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["files", token, config?.path],
|
||||
queryFn: () => OpenHands.getFiles(config?.path),
|
||||
enabled: !!(isActive && config?.enabled && token),
|
||||
queryKey: ["files", conversationId, config?.path],
|
||||
queryFn: () => OpenHands.getFiles(conversationId, config?.path),
|
||||
enabled: !!(isActive && config?.enabled),
|
||||
});
|
||||
};
|
||||
|
||||
24
frontend/src/hooks/query/use-search-events.ts
Normal file
24
frontend/src/hooks/query/use-search-events.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useSearchEvents = (params: {
|
||||
query?: string;
|
||||
startId?: number;
|
||||
limit?: number;
|
||||
eventType?: string;
|
||||
source?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}) => {
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["search_events", conversationId, params],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return OpenHands.searchEvents(conversationId, params);
|
||||
},
|
||||
enabled: !!conversationId,
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user