diff --git a/.github/workflows/build-openhands-image.yml b/.github/workflows/build-openhands-image.yml new file mode 100644 index 000000000000..270ff143e745 --- /dev/null +++ b/.github/workflows/build-openhands-image.yml @@ -0,0 +1,52 @@ + +name: Build OpenHands Image + +on: + push: + branches: + - release/stable-with-patches + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: pipx install poetry + + - name: Install dependencies + run: poetry install --no-interaction --no-ansi + + + - name: Build and Push OpenHands Image + run: | + echo "Building openhands image with tag stable-with-patches and git sha tag..." + # Set DOCKER_IMAGE_TAG for build.sh to pick up + export DOCKER_IMAGE_TAG=stable-with-patches + # Set RELEVANT_SHA for build.sh to add git sha tag + export RELEVANT_SHA=${{ github.sha }} + ./containers/build.sh -i openhands --push -o remind101 + env: + # Pass GITHUB_TOKEN for potential git operations within build script if needed + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-runtime-image.yml b/.github/workflows/build-runtime-image.yml new file mode 100644 index 000000000000..d4dca327c170 --- /dev/null +++ b/.github/workflows/build-runtime-image.yml @@ -0,0 +1,66 @@ + +name: Build Runtime Image + +on: + push: + branches: + - release/stable-with-patches + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: pipx install poetry + + - name: Install dependencies + run: poetry install --no-interaction --no-ansi + + + - name: Prepare Runtime Build Context + run: poetry run python openhands/runtime/utils/runtime_build.py --build_folder ./containers/runtime + env: + # Ensure the script uses the correct base image if needed, although default should be fine + # OH_RUNTIME_BASE_IMAGE: nikolaik/python-nodejs:python3.12-nodejs22 + # Ensure the script knows the target repo (though build.sh overrides org later) + OH_RUNTIME_RUNTIME_IMAGE_REPO: ghcr.io/remind101/runtime # This might not be strictly necessary as build.sh constructs the final path + + + - name: Free up disk space by removing tool cache + run: sudo rm -rf /opt/hostedtoolcache + + - name: Build and Push Runtime Image + run: | + echo "Building runtime image with tag stable-with-patches and source tag..." + # Set DOCKER_IMAGE_TAG for build.sh to pick up + export DOCKER_IMAGE_TAG=stable-with-patches + # Override the image name defined in config.sh + export DOCKER_IMAGE=openhands-runtime + # Set RELEVANT_SHA for build.sh to add git sha tag (optional but good practice) + export RELEVANT_SHA=${{ github.sha }} + ./containers/build.sh -i runtime --push -o remind101 + env: + # Pass GITHUB_TOKEN for potential git operations within build script if needed + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml index 618b438e6436..88a2a599886a 100644 --- a/.github/workflows/openhands-resolver.yml +++ b/.github/workflows/openhands-resolver.yml @@ -39,6 +39,10 @@ on: required: false LLM_API_KEY: required: true + APP_ID: + required: false + APP_PRIVATE_KEY: + required: false LLM_BASE_URL: required: false PAT_TOKEN: @@ -89,15 +93,48 @@ jobs: with: python-version: "3.12" - - name: Get latest versions and create requirements.txt - run: | - python -m pip index versions openhands-ai > openhands_versions.txt - OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()') + - name: Generate GitHub App Token + id: generate-token + # Only run if App ID and Key are provided via secrets + if: secrets.APP_ID && secrets.APP_PRIVATE_KEY + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - # Create a new requirements.txt locally within the workflow, ensuring no reference to the repo's file - echo "openhands-ai==${OPENHANDS_VERSION}" > /tmp/requirements.txt + - name: Determine Auth Token + id: determine-auth-token + run: | + if [ -n "${{ steps.generate-token.outputs.token }}" ]; then + echo "Using GitHub App Token" + echo "AUTH_TOKEN=${{ steps.generate-token.outputs.token }}" >> $GITHUB_ENV + elif [ -n "${{ secrets.PAT_TOKEN }}" ]; then + echo "Using PAT Token" + echo "AUTH_TOKEN=${{ secrets.PAT_TOKEN }}" >> $GITHUB_ENV + else + echo "Using default GITHUB_TOKEN" + echo "AUTH_TOKEN=${{ github.token }}" >> $GITHUB_ENV + fi + - name: Create requirements.txt and get branch SHA + id: setup_reqs_and_sha + env: + # Use the determined auth token for git clone and ls-remote + GIT_TOKEN: ${{ env.AUTH_TOKEN }} + run: | + echo "Using openhands-ai from remind101/OpenHands@release/stable-with-patches" + # Create a new requirements.txt locally within the workflow + echo "git+https://${GIT_TOKEN}@github.com/remind101/OpenHands.git@release/stable-with-patches#egg=openhands-ai" > /tmp/requirements.txt cat /tmp/requirements.txt + echo "Fetching latest commit SHA for release/stable-with-patches..." + SHA=$(git ls-remote https://${GIT_TOKEN}@github.com/remind101/OpenHands.git refs/heads/release/stable-with-patches | awk '{print $1}') + echo "Latest SHA: $SHA" + if [ -z "$SHA" ]; then + echo "Error: Could not retrieve SHA for release/stable-with-patches branch." + exit 1 + fi + echo "OPENHANDS_BRANCH_SHA=$SHA" >> $GITHUB_ENV + - name: Cache pip dependencies if: | !( @@ -114,9 +151,10 @@ jobs: uses: actions/cache@v4 with: path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/* - key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }} + key: ${{ runner.os }}-pip-openhands-resolver-${{ env.OPENHANDS_BRANCH_SHA }} restore-keys: | - ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }} + ${{ runner.os }}-pip-openhands-resolver-${{ env.OPENHANDS_BRANCH_SHA }} + ${{ runner.os }}-pip-openhands-resolver- - name: Check required environment variables env: @@ -126,7 +164,7 @@ jobs: LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }} PAT_TOKEN: ${{ secrets.PAT_TOKEN }} PAT_USERNAME: ${{ secrets.PAT_USERNAME }} - GITHUB_TOKEN: ${{ github.token }} + APP_TOKEN_GENERATED: ${{ steps.generate-token.outputs.token && 'true' || 'false' }} run: | required_vars=("LLM_API_KEY") for var in "${required_vars[@]}"; do @@ -141,8 +179,13 @@ jobs: 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" + # Check auth token source + if [ "$APP_TOKEN_GENERATED" == "true" ]; then + echo "Info: Using GitHub App Token for authentication." + elif [ -n "$PAT_TOKEN" ]; then + echo "Info: Using PAT_TOKEN for authentication." + else + echo "Warning: Neither App Token nor PAT_TOKEN is set, falling back to default GITHUB_TOKEN. This may have insufficient permissions." fi if [ -z "$PAT_USERNAME" ]; then @@ -178,8 +221,8 @@ jobs: fi echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV - echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.PAT_TOKEN || github.token }}" >> $GITHUB_ENV - echo "SANDBOX_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV + echo "SANDBOX_ENV_GITHUB_TOKEN=${{ env.AUTH_TOKEN }}" >> $GITHUB_ENV + echo "SANDBOX_ENV_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV # Set branch variables echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV @@ -187,7 +230,7 @@ jobs: - name: Comment on issue with start message uses: actions/github-script@v7 with: - github-token: ${{ secrets.PAT_TOKEN || github.token }} + github-token: ${{ env.AUTH_TOKEN }} script: | const issueType = process.env.ISSUE_TYPE; github.rest.issues.createComment({ @@ -235,7 +278,7 @@ jobs: - name: Attempt to resolve issue env: - GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }} + GITHUB_TOKEN: ${{ env.AUTH_TOKEN }} GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }} GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }} LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }} @@ -272,7 +315,7 @@ jobs: - name: Create draft PR or push branch if: always() # Create PR or branch even if the previous steps fail env: - GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }} + GITHUB_TOKEN: ${{ env.AUTH_TOKEN }} GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }} GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }} LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }} @@ -304,7 +347,7 @@ jobs: AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }} ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }} with: - github-token: ${{ secrets.PAT_TOKEN || github.token }} + github-token: ${{ env.AUTH_TOKEN }} script: | const fs = require('fs'); const issueNumber = process.env.ISSUE_NUMBER; @@ -341,7 +384,7 @@ jobs: ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }} RESOLUTION_SUCCESS: ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }} with: - github-token: ${{ secrets.PAT_TOKEN || github.token }} + github-token: ${{ env.AUTH_TOKEN }} script: | const fs = require('fs'); const path = require('path'); @@ -414,7 +457,7 @@ jobs: env: ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }} with: - github-token: ${{ secrets.PAT_TOKEN || github.token }} + github-token: ${{ env.AUTH_TOKEN }} script: | const issueNumber = process.env.ISSUE_NUMBER; diff --git a/Development.md b/Development.md index 7bbec7fabd83..832c1a62740c 100644 --- a/Development.md +++ b/Development.md @@ -118,7 +118,7 @@ poetry run pytest ./tests/unit/test_*.py To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image. -Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.33-nikolaik` +Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.34-nikolaik` ## Develop inside Docker container diff --git a/Makefile b/Makefile index aebdf8d90b3d..398f8d99f07a 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ check-dependencies: ifeq ($(INSTALL_DOCKER),) @$(MAKE) -s check-docker endif + @$(MAKE) -s check-tmux @$(MAKE) -s check-poetry @echo "$(GREEN)Dependencies checked successfully.$(RESET)" @@ -101,6 +102,15 @@ check-docker: exit 1; \ fi +check-tmux: + @echo "$(YELLOW)Checking tmux installation...$(RESET)" + @if command -v tmux > /dev/null; then \ + echo "$(BLUE)$(shell tmux -V) is already installed.$(RESET)"; \ + else \ + echo "$(RED)tmux is not installed. Please install tmux to continue.$(RESET)"; \ + exit 1; \ + fi + check-poetry: @echo "$(YELLOW)Checking Poetry installation...$(RESET)" @if command -v poetry > /dev/null; then \ diff --git a/README.md b/README.md index 570a1d238130..67e0b751deb5 100644 --- a/README.md +++ b/README.md @@ -52,17 +52,17 @@ system requirements and more information. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)! diff --git a/containers/app/Dockerfile b/containers/app/Dockerfile index 1e71055ef26b..5b2a99470b9f 100644 --- a/containers/app/Dockerfile +++ b/containers/app/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app COPY ./frontend/package.json frontend/package-lock.json ./ RUN npm install -g npm@10.5.1 -RUN npm ci +RUN npm ci --network-timeout 600000 COPY ./frontend ./ RUN npm run build @@ -63,6 +63,29 @@ RUN useradd -l -m -u $OPENHANDS_USER_ID -s /bin/bash openhands && \ echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers RUN chown -R openhands:app /app && chmod -R 770 /app RUN sudo chown -R openhands:app $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE + +# Install Playwright Chromium dependencies as root +USER root +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 \ + libnspr4 \ + libdbus-1-3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libgbm1 \ + libatspi2.0-0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libxkbcommon0 \ + libpango-1.0-0 \ + libcairo2 \ + libasound2 \ + && rm -rf /var/lib/apt/lists/* + USER openhands ENV VIRTUAL_ENV=/app/.venv \ @@ -70,7 +93,7 @@ ENV VIRTUAL_ENV=/app/.venv \ PYTHONPATH='/app' COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} -RUN playwright install --with-deps chromium +RUN playwright install chromium COPY --chown=openhands:app --chmod=770 ./microagents ./microagents COPY --chown=openhands:app --chmod=770 ./openhands ./openhands diff --git a/containers/build.sh b/containers/build.sh index 72a1d2c117de..becfcf4bd197 100755 --- a/containers/build.sh +++ b/containers/build.sh @@ -125,7 +125,7 @@ done if [[ $push -eq 1 ]]; then args+=" --push" - args+=" --cache-to=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag,mode=max" + # args+=" --cache-to=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag,mode=max" fi if [[ $load -eq 1 ]]; then @@ -168,8 +168,6 @@ echo "Building for platform(s): $platform" docker buildx build \ $args \ --build-arg OPENHANDS_BUILD_VERSION="$OPENHANDS_BUILD_VERSION" \ - --cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag \ - --cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag_base-main \ --platform $platform \ --provenance=false \ -f "$dir/Dockerfile" \ diff --git a/containers/dev/compose.yml b/containers/dev/compose.yml index c865613f4829..9dadad9855e3 100644 --- a/containers/dev/compose.yml +++ b/containers/dev/compose.yml @@ -11,7 +11,7 @@ services: - BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"} - SANDBOX_API_HOSTNAME=host.docker.internal # - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.33-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.34-nikolaik} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/docker-compose.yml b/docker-compose.yml index b3f962629220..246baec5f950 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: image: openhands:latest container_name: openhands-app-${DATE:-} environment: - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik} #- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index ef41167a31fe..859e90fea81a 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -61,7 +61,7 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index 48527fefc63d..e408552d2d53 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -56,6 +56,6 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue ``` diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx index d4803f76b4fa..1cca4aaf7cc3 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx @@ -13,16 +13,16 @@ La façon la plus simple d'exécuter OpenHands est avec Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action). diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md index abe1ac1e685b..047aafdfbba2 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md @@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands. ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index e43c4666ce7b..22a2c0b0cd52 100644 --- a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -44,7 +44,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index 566f61ee530c..3e0ba467ae8b 100644 --- a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -42,7 +42,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "write a bash script that prints hi" ``` diff --git a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/runtimes/docker.md b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/runtimes/docker.md index 8194a4350990..edc692b1ed2d 100644 --- a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/runtimes/docker.md +++ b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/runtimes/docker.md @@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー ```bash docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -v $WORKSPACE_BASE:/opt/workspace_base \ @@ -82,5 +82,5 @@ docker network create openhands-network # 分離されたネットワークで OpenHands を実行 docker run # ... \ --network openhands-network \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` diff --git a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index 393cd8bacc5e..acc41b9c8dd4 100644 --- a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -45,7 +45,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index b200fa23ac84..13995f4e8c9d 100644 --- a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -43,7 +43,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "escreva um script bash que imprima oi" ``` diff --git a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/installation.mdx index cd531ab53220..dd4e87aec017 100644 --- a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/installation.mdx +++ b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/installation.mdx @@ -58,17 +58,17 @@ A maneira mais fácil de executar o OpenHands é no Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` Você encontrará o OpenHands em execução em http://localhost:3000! diff --git a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/runtimes.md index d83905b159e5..b71cc89f42d4 100644 --- a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/runtimes.md +++ b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/runtimes.md @@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index 2ad414662c0e..2c446a980143 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -59,7 +59,7 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index 6ecc9104bce4..b9b7e6c2651f 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -57,6 +57,6 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue ``` diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx index 7c9c8c0af5b8..1c137961c7c0 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx @@ -11,16 +11,16 @@ 在 Docker 中运行 OpenHands 是最简单的方式。 ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` 你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。 diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md index 69848dc31be6..40c1e3c291d0 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md @@ -11,7 +11,7 @@ ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/docs/modules/usage/how-to/cli-mode.md b/docs/modules/usage/how-to/cli-mode.md index f5dab5c72f36..712ad368aefd 100644 --- a/docs/modules/usage/how-to/cli-mode.md +++ b/docs/modules/usage/how-to/cli-mode.md @@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -45,7 +45,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/modules/usage/how-to/headless-mode.md b/docs/modules/usage/how-to/headless-mode.md index ea12d5ef648f..6846ce92f70b 100644 --- a/docs/modules/usage/how-to/headless-mode.md +++ b/docs/modules/usage/how-to/headless-mode.md @@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -43,7 +43,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "write a bash script that prints hi" ``` diff --git a/docs/modules/usage/installation.mdx b/docs/modules/usage/installation.mdx index 274dda1a1511..a836c8bfbfd3 100644 --- a/docs/modules/usage/installation.mdx +++ b/docs/modules/usage/installation.mdx @@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to The easiest way to run OpenHands is in Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` You'll find OpenHands running at http://localhost:3000! diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit index 89779658ae7d..1a11f6a308bc 100755 --- a/frontend/.husky/pre-commit +++ b/frontend/.husky/pre-commit @@ -1,3 +1,10 @@ +# Run frontend checks +echo "Running frontend checks..." cd frontend npm run check-unlocalized-strings npx lint-staged + +# Run backend pre-commit +echo "Running backend pre-commit..." +cd .. +pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index 69c67406b6ae..5b48db05eb9c 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -45,7 +45,7 @@ describe("Empty state", () => { it("should render suggestions if empty", () => { const { store } = renderWithProviders(, { preloadedState: { - chat: { + chat: { messages: [], systemMessage: { content: "", @@ -76,7 +76,7 @@ describe("Empty state", () => { it("should render the default suggestions", () => { renderWithProviders(, { preloadedState: { - chat: { + chat: { messages: [], systemMessage: { content: "", @@ -114,7 +114,7 @@ describe("Empty state", () => { const user = userEvent.setup(); const { store } = renderWithProviders(, { preloadedState: { - chat: { + chat: { messages: [], systemMessage: { content: "", @@ -151,7 +151,7 @@ describe("Empty state", () => { const user = userEvent.setup(); const { rerender } = renderWithProviders(, { preloadedState: { - chat: { + chat: { messages: [], systemMessage: { content: "", diff --git a/frontend/__tests__/components/chat/expandable-message.test.tsx b/frontend/__tests__/components/chat/expandable-message.test.tsx index 3e4f2c3d39f7..2da47c6d4aea 100644 --- a/frontend/__tests__/components/chat/expandable-message.test.tsx +++ b/frontend/__tests__/components/chat/expandable-message.test.tsx @@ -95,6 +95,23 @@ describe("ExpandableMessage", () => { expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); }); + it("should render with neutral border and no icon for action messages with undefined success (timeout case)", () => { + renderWithProviders( + , + ); + 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(); + }); + it("should render the out of credits message when the user is out of credits", async () => { const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); // @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx index 898570161e0e..13260e6933e2 100644 --- a/frontend/__tests__/components/features/home/repo-connector.test.tsx +++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx @@ -74,7 +74,8 @@ describe("RepoConnector", () => { renderRepoConnector(); - const dropdown = screen.getByTestId("repo-dropdown"); + // Wait for the loading state to be replaced with the dropdown + const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown")); await userEvent.click(dropdown); await waitFor(() => { @@ -98,7 +99,8 @@ describe("RepoConnector", () => { const launchButton = screen.getByTestId("repo-launch-button"); expect(launchButton).toBeDisabled(); - const dropdown = screen.getByTestId("repo-dropdown"); + // Wait for the loading state to be replaced with the dropdown + const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown")); await userEvent.click(dropdown); await userEvent.click(screen.getByText("rbren/polaris")); @@ -132,6 +134,14 @@ describe("RepoConnector", () => { it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => { const createConversationSpy = vi.spyOn(OpenHands, "createConversation"); + const retrieveUserGitRepositoriesSpy = vi.spyOn( + GitService, + "retrieveUserGitRepositories", + ); + retrieveUserGitRepositoriesSpy.mockResolvedValue({ + data: MOCK_RESPOSITORIES, + nextPage: null, + }); renderRepoConnector(); @@ -144,7 +154,9 @@ describe("RepoConnector", () => { expect(createConversationSpy).not.toHaveBeenCalled(); // select a repository from the dropdown - const dropdown = within(repoConnector).getByTestId("repo-dropdown"); + const dropdown = await waitFor(() => + within(repoConnector).getByTestId("repo-dropdown") + ); await userEvent.click(dropdown); const repoOption = screen.getByText("rbren/polaris"); @@ -178,7 +190,8 @@ describe("RepoConnector", () => { const launchButton = screen.getByTestId("repo-launch-button"); - const dropdown = screen.getByTestId("repo-dropdown"); + // Wait for the loading state to be replaced with the dropdown + const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown")); await userEvent.click(dropdown); await userEvent.click(screen.getByText("rbren/polaris")); diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx index 83ddc95848b9..d86d31e85b62 100644 --- a/frontend/__tests__/routes/settings-with-payment.test.tsx +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -43,10 +43,12 @@ describe("Settings Billing", () => { renderSettingsScreen(); - await waitFor(() => { - const navbar = screen.queryByTestId("settings-navbar"); - expect(navbar).not.toBeInTheDocument(); - }); + // Wait for the settings screen to be rendered + await screen.findByTestId("settings-screen"); + + // Then check that the navbar is not present + const navbar = screen.queryByTestId("settings-navbar"); + expect(navbar).not.toBeInTheDocument(); }); it("should render the navbar if SaaS mode", async () => { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 31d435dc2419..52b4b9afaf86 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "openhands-frontend", - "version": "0.33.0", + "version": "0.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "0.33.0", + "version": "0.34.0", "dependencies": { "@heroui/react": "2.7.6", "@microlink/react-json-view": "^1.26.1", diff --git a/frontend/package.json b/frontend/package.json index cac7315c0463..da55f5a5a8c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "openhands-frontend", - "version": "0.33.0", + "version": "0.34.0", "private": true, "type": "module", "engines": { diff --git a/frontend/src/api/api-keys.ts b/frontend/src/api/api-keys.ts new file mode 100644 index 000000000000..cfcad2fede69 --- /dev/null +++ b/frontend/src/api/api-keys.ts @@ -0,0 +1,49 @@ +import { openHands } from "./open-hands-axios"; + +export interface ApiKey { + id: string; + name: string; + prefix: string; + created_at: string; + last_used_at: string | null; +} + +export interface CreateApiKeyResponse { + id: string; + name: string; + key: string; // Full key, only returned once upon creation + prefix: string; + created_at: string; +} + +class ApiKeysClient { + /** + * Get all API keys for the current user + */ + static async getApiKeys(): Promise { + const { data } = await openHands.get("/api/keys"); + // Ensure we always return an array, even if the API returns something else + return Array.isArray(data) ? (data as ApiKey[]) : []; + } + + /** + * Create a new API key + * @param name - A descriptive name for the API key + */ + static async createApiKey(name: string): Promise { + const { data } = await openHands.post("/api/keys", { + name, + }); + return data; + } + + /** + * Delete an API key + * @param id - The ID of the API key to delete + */ + static async deleteApiKey(id: string): Promise { + await openHands.delete(`/api/keys/${id}`); + } +} + +export default ApiKeysClient; diff --git a/frontend/src/components/features/home/repo-selection-form.test.tsx b/frontend/src/components/features/home/repo-selection-form.test.tsx new file mode 100644 index 000000000000..1f6634dd7347 --- /dev/null +++ b/frontend/src/components/features/home/repo-selection-form.test.tsx @@ -0,0 +1,138 @@ +import { render, screen } from "@testing-library/react"; +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { RepositorySelectionForm } from "./repo-selection-form"; + +// Create mock functions +const mockUseUserRepositories = vi.fn(); +const mockUseCreateConversation = vi.fn(); +const mockUseIsCreatingConversation = vi.fn(); +const mockUseTranslation = vi.fn(); +const mockUseAuth = vi.fn(); + +// Setup default mock returns +mockUseUserRepositories.mockReturnValue({ + data: { pages: [{ data: [] }] }, + isLoading: false, + isError: false, +}); + +mockUseCreateConversation.mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isSuccess: false, +}); + +mockUseIsCreatingConversation.mockReturnValue(false); + +mockUseTranslation.mockReturnValue({ t: (key: string) => key }); + +mockUseAuth.mockReturnValue({ + isAuthenticated: true, + isLoading: false, + providersAreSet: true, + user: { + id: 1, + login: "testuser", + avatar_url: "https://example.com/avatar.png", + name: "Test User", + email: "test@example.com", + company: "Test Company", + }, + login: vi.fn(), + logout: vi.fn(), +}); + +// Mock the modules +vi.mock("#/hooks/query/use-user-repositories", () => ({ + useUserRepositories: () => mockUseUserRepositories(), +})); + +vi.mock("#/hooks/mutation/use-create-conversation", () => ({ + useCreateConversation: () => mockUseCreateConversation(), +})); + +vi.mock("#/hooks/use-is-creating-conversation", () => ({ + useIsCreatingConversation: () => mockUseIsCreatingConversation(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => mockUseTranslation(), +})); + +vi.mock("#/context/auth-context", () => ({ + useAuth: () => mockUseAuth(), +})); + +describe("RepositorySelectionForm", () => { + const mockOnRepoSelection = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("shows loading indicator when repositories are being fetched", () => { + // Setup loading state + mockUseUserRepositories.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + + render(); + + // Check if loading indicator is displayed + expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument(); + expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument(); + }); + + test("shows dropdown when repositories are loaded", () => { + // Setup loaded repositories + mockUseUserRepositories.mockReturnValue({ + data: { + pages: [ + { + data: [ + { + id: 1, + full_name: "user/repo1", + git_provider: "github", + is_public: true, + }, + { + id: 2, + full_name: "user/repo2", + git_provider: "github", + is_public: true, + }, + ], + }, + ], + }, + isLoading: false, + isError: false, + }); + + render(); + + // Check if dropdown is displayed + expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument(); + }); + + test("shows error message when repository fetch fails", () => { + // Setup error state + mockUseUserRepositories.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error("Failed to fetch repositories"), + }); + + render(); + + // Check if error message is displayed + expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument(); + expect( + screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/features/home/repo-selection-form.tsx b/frontend/src/components/features/home/repo-selection-form.tsx index 997878979a1e..514f9fe55e97 100644 --- a/frontend/src/components/features/home/repo-selection-form.tsx +++ b/frontend/src/components/features/home/repo-selection-form.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { Spinner } from "@heroui/react"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; import { useUserRepositories } from "#/hooks/query/use-user-repositories"; import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; @@ -11,12 +12,68 @@ interface RepositorySelectionFormProps { onRepoSelection: (repoTitle: string | null) => void; } +// Loading state component +function RepositoryLoadingState() { + const { t } = useTranslation(); + return ( +
+ + {t("HOME$LOADING_REPOSITORIES")} +
+ ); +} + +// Error state component +function RepositoryErrorState() { + const { t } = useTranslation(); + return ( +
+ {t("HOME$FAILED_TO_LOAD_REPOSITORIES")} +
+ ); +} + +// Repository dropdown component +interface RepositoryDropdownProps { + items: { key: React.Key; label: string }[]; + onSelectionChange: (key: React.Key | null) => void; + onInputChange: (value: string) => void; +} + +function RepositoryDropdown({ + items, + onSelectionChange, + onInputChange, +}: RepositoryDropdownProps) { + return ( + + ); +} + export function RepositorySelectionForm({ onRepoSelection, }: RepositorySelectionFormProps) { const [selectedRepository, setSelectedRepository] = React.useState(null); - const { data: repositories } = useUserRepositories(); + const { + data: repositories, + isLoading: isLoadingRepositories, + isError: isRepositoriesError, + } = useUserRepositories(); const { mutate: createConversation, isPending, @@ -52,23 +109,39 @@ export function RepositorySelectionForm({ } }; - return ( - <> - { + if (isLoadingRepositories) { + return ; + } + + if (isRepositoriesError) { + return ; + } + + return ( + + ); + }; + + return ( + <> + {renderRepositorySelector()} createConversation({ selectedRepository })} > {!isCreatingConversation && "Launch"} diff --git a/frontend/src/components/features/payment/payment-form.tsx b/frontend/src/components/features/payment/payment-form.tsx index 1c8b0a641785..bc337c05dfa8 100644 --- a/frontend/src/components/features/payment/payment-form.tsx +++ b/frontend/src/components/features/payment/payment-form.tsx @@ -40,10 +40,6 @@ export function PaymentForm() { data-testid="billing-settings" className="flex flex-col gap-6 px-11 py-9" > -

- {t(I18nKey.PAYMENT$MANAGE_CREDITS)} -

-
- Balance + {t(I18nKey.PAYMENT$MANAGE_CREDITS)}
{!isLoading && ( ${Number(balance).toFixed(2)} diff --git a/frontend/src/components/features/settings/api-key-modal-base.tsx b/frontend/src/components/features/settings/api-key-modal-base.tsx new file mode 100644 index 000000000000..43d8ba89f028 --- /dev/null +++ b/frontend/src/components/features/settings/api-key-modal-base.tsx @@ -0,0 +1,33 @@ +import React, { ReactNode } from "react"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; + +interface ApiKeyModalBaseProps { + isOpen: boolean; + title: string; + width?: string; + children: ReactNode; + footer: ReactNode; +} + +export function ApiKeyModalBase({ + isOpen, + title, + width = "500px", + children, + footer, +}: ApiKeyModalBaseProps) { + if (!isOpen) return null; + + return ( + +
+

{title}

+ {children} +
{footer}
+
+
+ ); +} diff --git a/frontend/src/components/features/settings/api-keys-manager.tsx b/frontend/src/components/features/settings/api-keys-manager.tsx new file mode 100644 index 000000000000..2490d4bce04f --- /dev/null +++ b/frontend/src/components/features/settings/api-keys-manager.tsx @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { CreateApiKeyModal } from "./create-api-key-modal"; +import { DeleteApiKeyModal } from "./delete-api-key-modal"; +import { NewApiKeyModal } from "./new-api-key-modal"; +import { useApiKeys } from "#/hooks/query/use-api-keys"; + +export function ApiKeysManager() { + const { t } = useTranslation(); + const { data: apiKeys = [], isLoading, error } = useApiKeys(); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [keyToDelete, setKeyToDelete] = useState(null); + const [newlyCreatedKey, setNewlyCreatedKey] = + useState(null); + const [showNewKeyModal, setShowNewKeyModal] = useState(false); + + // Display error toast if the query fails + if (error) { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + + const handleKeyCreated = (newKey: CreateApiKeyResponse) => { + setNewlyCreatedKey(newKey); + setCreateModalOpen(false); + setShowNewKeyModal(true); + }; + + const handleCloseCreateModal = () => { + setCreateModalOpen(false); + }; + + const handleCloseDeleteModal = () => { + setDeleteModalOpen(false); + setKeyToDelete(null); + }; + + const handleCloseNewKeyModal = () => { + setShowNewKeyModal(false); + setNewlyCreatedKey(null); + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return "Never"; + return new Date(dateString).toLocaleString(); + }; + + return ( + <> +
+
+ setCreateModalOpen(true)} + > + {t(I18nKey.SETTINGS$CREATE_API_KEY)} + +
+ +

+ {t(I18nKey.SETTINGS$API_KEYS_DESCRIPTION)} +

+ + {isLoading && ( +
+ +
+ )} + {!isLoading && Array.isArray(apiKeys) && apiKeys.length > 0 && ( +
+ + + + + + + + + + + {apiKeys.map((key) => ( + + + + + + + ))} + +
+ {t(I18nKey.SETTINGS$NAME)} + + {t(I18nKey.SETTINGS$CREATED_AT)} + + {t(I18nKey.SETTINGS$LAST_USED)} + + {t(I18nKey.SETTINGS$ACTIONS)} +
{key.name} + {formatDate(key.created_at)} + + {formatDate(key.last_used_at)} + + +
+
+ )} +
+ + {/* Create API Key Modal */} + + + {/* Delete API Key Modal */} + + + {/* Show New API Key Modal */} + + + ); +} diff --git a/frontend/src/components/features/settings/brand-button.tsx b/frontend/src/components/features/settings/brand-button.tsx index 03210f46e9e8..e13a2aa2d51c 100644 --- a/frontend/src/components/features/settings/brand-button.tsx +++ b/frontend/src/components/features/settings/brand-button.tsx @@ -2,7 +2,7 @@ import { cn } from "#/utils/utils"; interface BrandButtonProps { testId?: string; - variant: "primary" | "secondary"; + variant: "primary" | "secondary" | "danger"; type: React.ButtonHTMLAttributes["type"]; isDisabled?: boolean; className?: string; @@ -32,6 +32,7 @@ export function BrandButton({ "w-fit p-2 text-sm rounded disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80", variant === "primary" && "bg-primary text-[#0D0F11]", variant === "secondary" && "border border-primary text-primary", + variant === "danger" && "bg-red-600 text-white hover:bg-red-700", startContent && "flex items-center justify-center gap-2", className, )} diff --git a/frontend/src/components/features/settings/create-api-key-modal.tsx b/frontend/src/components/features/settings/create-api-key-modal.tsx new file mode 100644 index 000000000000..b97d29f349b6 --- /dev/null +++ b/frontend/src/components/features/settings/create-api-key-modal.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { CreateApiKeyResponse } from "#/api/api-keys"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { ApiKeyModalBase } from "./api-key-modal-base"; +import { useCreateApiKey } from "#/hooks/mutation/use-create-api-key"; + +interface CreateApiKeyModalProps { + isOpen: boolean; + onClose: () => void; + onKeyCreated: (newKey: CreateApiKeyResponse) => void; +} + +export function CreateApiKeyModal({ + isOpen, + onClose, + onKeyCreated, +}: CreateApiKeyModalProps) { + const { t } = useTranslation(); + const [newKeyName, setNewKeyName] = useState(""); + + const createApiKeyMutation = useCreateApiKey(); + + const handleCreateKey = async () => { + if (!newKeyName.trim()) { + displayErrorToast(t(I18nKey.ERROR$REQUIRED_FIELD)); + return; + } + + try { + const newKey = await createApiKeyMutation.mutateAsync(newKeyName); + onKeyCreated(newKey); + displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_CREATED)); + setNewKeyName(""); + } catch (error) { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + }; + + const handleCancel = () => { + setNewKeyName(""); + onClose(); + }; + + const modalFooter = ( + <> + + {createApiKeyMutation.isPending ? ( + + ) : ( + t(I18nKey.BUTTON$CREATE) + )} + + + {t(I18nKey.BUTTON$CANCEL)} + + + ); + + return ( + +
+

+ {t(I18nKey.SETTINGS$CREATE_API_KEY_DESCRIPTION)} +

+ setNewKeyName(value)} + className="w-full mt-4" + type="text" + /> +
+
+ ); +} diff --git a/frontend/src/components/features/settings/delete-api-key-modal.tsx b/frontend/src/components/features/settings/delete-api-key-modal.tsx new file mode 100644 index 000000000000..187507745839 --- /dev/null +++ b/frontend/src/components/features/settings/delete-api-key-modal.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ApiKey } from "#/api/api-keys"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { ApiKeyModalBase } from "./api-key-modal-base"; +import { useDeleteApiKey } from "#/hooks/mutation/use-delete-api-key"; + +interface DeleteApiKeyModalProps { + isOpen: boolean; + keyToDelete: ApiKey | null; + onClose: () => void; +} + +export function DeleteApiKeyModal({ + isOpen, + keyToDelete, + onClose, +}: DeleteApiKeyModalProps) { + const { t } = useTranslation(); + const deleteApiKeyMutation = useDeleteApiKey(); + + const handleDeleteKey = async () => { + if (!keyToDelete) return; + + try { + await deleteApiKeyMutation.mutateAsync(keyToDelete.id); + displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_DELETED)); + onClose(); + } catch (error) { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + }; + + if (!keyToDelete) return null; + + const modalFooter = ( + <> + + {deleteApiKeyMutation.isPending ? ( + + ) : ( + t(I18nKey.BUTTON$DELETE) + )} + + + {t(I18nKey.BUTTON$CANCEL)} + + + ); + + return ( + +
+

+ {t(I18nKey.SETTINGS$DELETE_API_KEY_CONFIRMATION, { + name: keyToDelete.name, + })} +

+
+
+ ); +} diff --git a/frontend/src/components/features/settings/new-api-key-modal.tsx b/frontend/src/components/features/settings/new-api-key-modal.tsx new file mode 100644 index 000000000000..2457f6a46ebc --- /dev/null +++ b/frontend/src/components/features/settings/new-api-key-modal.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { CreateApiKeyResponse } from "#/api/api-keys"; +import { displaySuccessToast } from "#/utils/custom-toast-handlers"; +import { ApiKeyModalBase } from "./api-key-modal-base"; + +interface NewApiKeyModalProps { + isOpen: boolean; + newlyCreatedKey: CreateApiKeyResponse | null; + onClose: () => void; +} + +export function NewApiKeyModal({ + isOpen, + newlyCreatedKey, + onClose, +}: NewApiKeyModalProps) { + const { t } = useTranslation(); + + const handleCopyToClipboard = () => { + if (newlyCreatedKey) { + navigator.clipboard.writeText(newlyCreatedKey.key); + displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_COPIED)); + } + }; + + if (!newlyCreatedKey) return null; + + const modalFooter = ( + <> + + {t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)} + + + {t(I18nKey.BUTTON$CLOSE)} + + + ); + + return ( + +
+

{t(I18nKey.SETTINGS$API_KEY_WARNING)}

+
+ {newlyCreatedKey.key} +
+
+
+ ); +} diff --git a/frontend/src/components/features/settings/settings-input.tsx b/frontend/src/components/features/settings/settings-input.tsx index 75bec0c9c164..4aad109f249d 100644 --- a/frontend/src/components/features/settings/settings-input.tsx +++ b/frontend/src/components/features/settings/settings-input.tsx @@ -7,6 +7,7 @@ interface SettingsInputProps { label: string; type: React.HTMLInputTypeAttribute; defaultValue?: string; + value?: string; placeholder?: string; showOptionalTag?: boolean; isDisabled?: boolean; @@ -24,6 +25,7 @@ export function SettingsInput({ label, type, defaultValue, + value, placeholder, showOptionalTag, isDisabled, @@ -43,11 +45,12 @@ export function SettingsInput({
onChange?.(e.target.value)} + onChange={(e) => onChange && onChange(e.target.value)} name={name} disabled={isDisabled} type={type} defaultValue={defaultValue} + value={value} placeholder={placeholder} min={min} max={max} diff --git a/frontend/src/hooks/mutation/use-create-api-key.ts b/frontend/src/hooks/mutation/use-create-api-key.ts new file mode 100644 index 000000000000..fd3c05c975ee --- /dev/null +++ b/frontend/src/hooks/mutation/use-create-api-key.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiKeysClient, { CreateApiKeyResponse } from "#/api/api-keys"; +import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys"; + +export function useCreateApiKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (name: string): Promise => + ApiKeysClient.createApiKey(name), + onSuccess: () => { + // Invalidate the API keys query to trigger a refetch + queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] }); + }, + }); +} diff --git a/frontend/src/hooks/mutation/use-delete-api-key.ts b/frontend/src/hooks/mutation/use-delete-api-key.ts new file mode 100644 index 000000000000..4f4b566fab8c --- /dev/null +++ b/frontend/src/hooks/mutation/use-delete-api-key.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiKeysClient from "#/api/api-keys"; +import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys"; + +export function useDeleteApiKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string): Promise => { + await ApiKeysClient.deleteApiKey(id); + }, + onSuccess: () => { + // Invalidate the API keys query to trigger a refetch + queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] }); + }, + }); +} diff --git a/frontend/src/hooks/query/use-api-keys.ts b/frontend/src/hooks/query/use-api-keys.ts new file mode 100644 index 000000000000..8549d441719b --- /dev/null +++ b/frontend/src/hooks/query/use-api-keys.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import ApiKeysClient from "#/api/api-keys"; +import { useConfig } from "./use-config"; +import { useAuth } from "#/context/auth-context"; + +export const API_KEYS_QUERY_KEY = "api-keys"; + +export function useApiKeys() { + const { providersAreSet } = useAuth(); + const { data: config } = useConfig(); + + return useQuery({ + queryKey: [API_KEYS_QUERY_KEY], + enabled: providersAreSet && config?.APP_MODE === "saas", + queryFn: async () => { + const keys = await ApiKeysClient.getApiKeys(); + return Array.isArray(keys) ? keys : []; + }, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 2b103cca86ce..f2c8b00511ec 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -6,6 +6,8 @@ export enum I18nKey { HOME$NOT_SURE_HOW_TO_START = "HOME$NOT_SURE_HOW_TO_START", HOME$CONNECT_TO_REPOSITORY = "HOME$CONNECT_TO_REPOSITORY", HOME$LOADING = "HOME$LOADING", + HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES", + HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES", HOME$OPEN_ISSUE = "HOME$OPEN_ISSUE", HOME$FIX_FAILING_CHECKS = "HOME$FIX_FAILING_CHECKS", HOME$RESOLVE_MERGE_CONFLICTS = "HOME$RESOLVE_MERGE_CONFLICTS", @@ -267,6 +269,27 @@ export enum I18nKey { SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS", SETTINGS$SAVED = "SETTINGS$SAVED", SETTINGS$RESET = "SETTINGS$RESET", + SETTINGS$API_KEYS = "SETTINGS$API_KEYS", + SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION", + SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY", + SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION", + SETTINGS$DELETE_API_KEY = "SETTINGS$DELETE_API_KEY", + SETTINGS$DELETE_API_KEY_CONFIRMATION = "SETTINGS$DELETE_API_KEY_CONFIRMATION", + SETTINGS$NO_API_KEYS = "SETTINGS$NO_API_KEYS", + SETTINGS$NAME = "SETTINGS$NAME", + SETTINGS$KEY_PREFIX = "SETTINGS$KEY_PREFIX", + SETTINGS$CREATED_AT = "SETTINGS$CREATED_AT", + SETTINGS$LAST_USED = "SETTINGS$LAST_USED", + SETTINGS$ACTIONS = "SETTINGS$ACTIONS", + SETTINGS$API_KEY_CREATED = "SETTINGS$API_KEY_CREATED", + SETTINGS$API_KEY_DELETED = "SETTINGS$API_KEY_DELETED", + SETTINGS$API_KEY_WARNING = "SETTINGS$API_KEY_WARNING", + SETTINGS$API_KEY_COPIED = "SETTINGS$API_KEY_COPIED", + SETTINGS$API_KEY_NAME_PLACEHOLDER = "SETTINGS$API_KEY_NAME_PLACEHOLDER", + BUTTON$CREATE = "BUTTON$CREATE", + BUTTON$DELETE = "BUTTON$DELETE", + BUTTON$COPY_TO_CLIPBOARD = "BUTTON$COPY_TO_CLIPBOARD", + ERROR$REQUIRED_FIELD = "ERROR$REQUIRED_FIELD", PLANNER$EMPTY_MESSAGE = "PLANNER$EMPTY_MESSAGE", FEEDBACK$PUBLIC_LABEL = "FEEDBACK$PUBLIC_LABEL", FEEDBACK$PRIVATE_LABEL = "FEEDBACK$PRIVATE_LABEL", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 17c03f413618..e33d5e8e79a6 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -89,6 +89,36 @@ "tr": "Yükleniyor...", "de": "Wird geladen..." }, + "HOME$LOADING_REPOSITORIES": { + "en": "Loading repositories...", + "ja": "リポジトリを読み込み中...", + "zh-CN": "加载仓库中...", + "zh-TW": "載入儲存庫中...", + "ko-KR": "저장소 로딩 중...", + "no": "Laster repositories...", + "it": "Caricamento repository in corso...", + "pt": "Carregando repositórios...", + "es": "Cargando repositorios...", + "ar": "جار تحميل المستودعات...", + "fr": "Chargement des dépôts...", + "tr": "Depolar yükleniyor...", + "de": "Repositories werden geladen..." + }, + "HOME$FAILED_TO_LOAD_REPOSITORIES": { + "en": "Failed to load repositories", + "ja": "リポジトリの読み込みに失敗しました", + "zh-CN": "加载仓库失败", + "zh-TW": "載入儲存庫失敗", + "ko-KR": "저장소 로딩 실패", + "no": "Kunne ikke laste repositories", + "it": "Impossibile caricare i repository", + "pt": "Falha ao carregar repositórios", + "es": "Error al cargar repositorios", + "ar": "فشل في تحميل المستودعات", + "fr": "Échec du chargement des dépôts", + "tr": "Depolar yüklenemedi", + "de": "Fehler beim Laden der Repositories" + }, "HOME$OPEN_ISSUE": { "en": "Open issue", "ja": "オープンな課題", @@ -3983,6 +4013,69 @@ "tr": "Ayarlar sıfırlandı", "de": "Einstellungen zurückgesetzt" }, + "SETTINGS$API_KEYS": { + "en": "API Keys" + }, + "SETTINGS$API_KEYS_DESCRIPTION": { + "en": "API keys allow you to authenticate with the OpenHands API programmatically. Keep your API keys secure; anyone with your API key can access your account." + }, + "SETTINGS$CREATE_API_KEY": { + "en": "Create API Key" + }, + "SETTINGS$CREATE_API_KEY_DESCRIPTION": { + "en": "Give your API key a descriptive name to help you identify it later." + }, + "SETTINGS$DELETE_API_KEY": { + "en": "Delete API Key" + }, + "SETTINGS$DELETE_API_KEY_CONFIRMATION": { + "en": "Are you sure you want to delete the API key \"{{name}}\"? This action cannot be undone." + }, + "SETTINGS$NO_API_KEYS": { + "en": "You don't have any API keys yet. Create one to get started." + }, + "SETTINGS$NAME": { + "en": "Name" + }, + "SETTINGS$KEY_PREFIX": { + "en": "Key Prefix" + }, + "SETTINGS$CREATED_AT": { + "en": "Created" + }, + "SETTINGS$LAST_USED": { + "en": "Last Used" + }, + "SETTINGS$ACTIONS": { + "en": "Actions" + }, + "SETTINGS$API_KEY_CREATED": { + "en": "API Key Created" + }, + "SETTINGS$API_KEY_DELETED": { + "en": "API key deleted successfully" + }, + "SETTINGS$API_KEY_WARNING": { + "en": "This is the only time your API key will be displayed. Please copy it now and store it securely." + }, + "SETTINGS$API_KEY_COPIED": { + "en": "API key copied to clipboard" + }, + "SETTINGS$API_KEY_NAME_PLACEHOLDER": { + "en": "My API Key" + }, + "BUTTON$CREATE": { + "en": "Create" + }, + "BUTTON$DELETE": { + "en": "Delete" + }, + "BUTTON$COPY_TO_CLIPBOARD": { + "en": "Copy to Clipboard" + }, + "ERROR$REQUIRED_FIELD": { + "en": "This field is required" + }, "PLANNER$EMPTY_MESSAGE": { "en": "No plan created.", "zh-CN": "计划未创建", diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 9c8f1e4bd899..3543a2f65ecc 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -11,6 +11,7 @@ export default [ route("settings", "routes/settings.tsx", [ index("routes/account-settings.tsx"), route("billing", "routes/billing.tsx"), + route("api-keys", "routes/api-keys.tsx"), ]), route("conversations/:conversationId", "routes/conversation.tsx", [ index("routes/editor.tsx"), diff --git a/frontend/src/routes/api-keys.tsx b/frontend/src/routes/api-keys.tsx new file mode 100644 index 000000000000..b1609c75f9c6 --- /dev/null +++ b/frontend/src/routes/api-keys.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"; + +function ApiKeysScreen() { + return ( +
+ +
+ ); +} + +export default ApiKeysScreen; diff --git a/frontend/src/routes/billing.tsx b/frontend/src/routes/billing.tsx index 7d1a4ead2e83..fdd410f6c4e3 100644 --- a/frontend/src/routes/billing.tsx +++ b/frontend/src/routes/billing.tsx @@ -1,25 +1,13 @@ -import { redirect, useSearchParams } from "react-router"; +import { useSearchParams } from "react-router"; import React from "react"; import { useTranslation } from "react-i18next"; import { PaymentForm } from "#/components/features/payment/payment-form"; -import { GetConfigResponse } from "#/api/open-hands.types"; -import { queryClient } from "#/entry.client"; import { displayErrorToast, displaySuccessToast, } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; -export const clientLoader = async () => { - const config = queryClient.getQueryData(["config"]); - - if (config?.APP_MODE !== "saas" || !config.FEATURE_FLAGS.ENABLE_BILLING) { - return redirect("/settings"); - } - - return null; -}; - function BillingSettingsScreen() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index b89fa2db868c..686e422c7f68 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -9,7 +9,6 @@ function SettingsScreen() { const { t } = useTranslation(); const { data: config } = useConfig(); const isSaas = config?.APP_MODE === "saas"; - const billingIsEnabled = config?.FEATURE_FLAGS.ENABLE_BILLING; return (
{t(I18nKey.SETTINGS$TITLE)} - {isSaas && billingIsEnabled && ( + {isSaas && (