mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'dev' into redesigning-block-menu
This commit is contained in:
313
.github/workflows/platform-autogpt-deploy-dev.yaml
vendored
313
.github/workflows/platform-autogpt-deploy-dev.yaml
vendored
@@ -1,51 +1,282 @@
|
||||
name: AutoGPT Platform - Deploy Dev Environment
|
||||
name: AutoGPT Platform - Dev Deploy PR Event Dispatcher
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ dev ]
|
||||
paths:
|
||||
- 'autogpt_platform/**'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
id-token: 'write'
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
migrate:
|
||||
environment: develop
|
||||
name: Run migrations for AutoGPT Platform
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install prisma
|
||||
|
||||
- name: Run Backend Migrations
|
||||
working-directory: ./autogpt_platform/backend
|
||||
run: |
|
||||
python -m prisma migrate deploy
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.BACKEND_DATABASE_URL }}
|
||||
DIRECT_URL: ${{ secrets.BACKEND_DATABASE_URL }}
|
||||
|
||||
trigger:
|
||||
needs: migrate
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger deploy workflow
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
- name: Check comment permissions and deployment status
|
||||
id: check_status
|
||||
if: github.event_name == 'issue_comment' && github.event.issue.pull_request
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
token: ${{ secrets.DEPLOY_TOKEN }}
|
||||
script: |
|
||||
const commentBody = context.payload.comment.body.trim();
|
||||
const commentUser = context.payload.comment.user.login;
|
||||
const prAuthor = context.payload.issue.user.login;
|
||||
const authorAssociation = context.payload.comment.author_association;
|
||||
const triggeringCommentId = context.payload.comment.id;
|
||||
|
||||
// Check permissions
|
||||
const hasPermission = (
|
||||
authorAssociation === 'OWNER' ||
|
||||
authorAssociation === 'MEMBER' ||
|
||||
authorAssociation === 'COLLABORATOR'
|
||||
);
|
||||
|
||||
core.setOutput('comment_body', commentBody);
|
||||
core.setOutput('has_permission', hasPermission);
|
||||
|
||||
if (!hasPermission && (commentBody === '!deploy' || commentBody === '!undeploy')) {
|
||||
core.setOutput('permission_denied', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (commentBody !== '!deploy' && commentBody !== '!undeploy') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all comments to check deployment status
|
||||
const commentsResponse = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter out the triggering comment
|
||||
const commentsData = commentsResponse.data.filter(comment => comment.id !== triggeringCommentId);
|
||||
|
||||
// Find the last deploy and undeploy commands
|
||||
let lastDeployIndex = -2;
|
||||
let lastUndeployIndex = -1;
|
||||
|
||||
console.log(`Found ${commentsResponse.data.length} total comments, using ${commentsData.length} for status check after filtering`);
|
||||
|
||||
// Iterate through comments in reverse to find the most recent commands
|
||||
for (let i = commentsData.length - 1; i >= 0; i--) {
|
||||
const currentCommentBody = commentsData[i].body.trim();
|
||||
console.log(`Processing comment ${i}: ${currentCommentBody}`);
|
||||
|
||||
if (currentCommentBody === '!deploy' && lastDeployIndex === -2) {
|
||||
lastDeployIndex = i;
|
||||
} else if (currentCommentBody === '!undeploy' && lastUndeployIndex === -1) {
|
||||
lastUndeployIndex = i;
|
||||
}
|
||||
|
||||
// Break early if we found both
|
||||
if (lastDeployIndex !== -2 && lastUndeployIndex !== -1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Last deploy index: ${lastDeployIndex}`);
|
||||
console.log(`Last undeploy index: ${lastUndeployIndex}`);
|
||||
|
||||
// Currently deployed if there's a deploy command after the last undeploy
|
||||
const isCurrentlyDeployed = lastDeployIndex > lastUndeployIndex;
|
||||
|
||||
// Determine actions based on current state and requested command
|
||||
if (commentBody === '!deploy') {
|
||||
if (isCurrentlyDeployed) {
|
||||
core.setOutput('deploy_blocked', 'already_deployed');
|
||||
} else {
|
||||
core.setOutput('should_deploy', 'true');
|
||||
}
|
||||
} else if (commentBody === '!undeploy') {
|
||||
if (!isCurrentlyDeployed) {
|
||||
// Check if there was ever a deploy
|
||||
const hasEverDeployed = lastDeployIndex !== -2;
|
||||
core.setOutput('undeploy_blocked', hasEverDeployed ? 'already_undeployed' : 'never_deployed');
|
||||
} else {
|
||||
core.setOutput('should_undeploy', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
core.setOutput('has_active_deployment', isCurrentlyDeployed);
|
||||
|
||||
- name: Post permission denied comment
|
||||
if: steps.check_status.outputs.permission_denied == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `❌ **Permission denied**: Only the repository owners, members, or collaborators can use deployment commands.`
|
||||
});
|
||||
|
||||
- name: Post deploy blocked comment
|
||||
if: steps.check_status.outputs.deploy_blocked == 'already_deployed'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `⚠️ **Deploy skipped**: This PR already has an active deployment. Use \`!undeploy\` first if you want to redeploy.`
|
||||
});
|
||||
|
||||
- name: Post undeploy blocked comment
|
||||
if: steps.check_status.outputs.undeploy_blocked != ''
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const reason = '${{ steps.check_status.outputs.undeploy_blocked }}';
|
||||
let message;
|
||||
|
||||
if (reason === 'never_deployed') {
|
||||
message = `⚠️ **Undeploy skipped**: This PR has never been deployed. Use \`!deploy\` first.`;
|
||||
} else if (reason === 'already_undeployed') {
|
||||
message = `⚠️ **Undeploy skipped**: This PR is already undeployed.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: message
|
||||
});
|
||||
|
||||
- name: Get PR details for deployment
|
||||
id: pr_details
|
||||
if: steps.check_status.outputs.should_deploy == 'true' || steps.check_status.outputs.should_undeploy == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
core.setOutput('pr_number', pr.data.number);
|
||||
core.setOutput('pr_title', pr.data.title);
|
||||
core.setOutput('pr_state', pr.data.state);
|
||||
|
||||
- name: Dispatch Deploy Event
|
||||
if: steps.check_status.outputs.should_deploy == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.DISPATCH_TOKEN }}
|
||||
repository: Significant-Gravitas/AutoGPT_cloud_infrastructure
|
||||
event-type: build_deploy_dev
|
||||
client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "repository": "${{ github.repository }}"}'
|
||||
event-type: pr-event
|
||||
client-payload: |
|
||||
{
|
||||
"action": "deploy",
|
||||
"pr_number": "${{ steps.pr_details.outputs.pr_number }}",
|
||||
"pr_title": "${{ steps.pr_details.outputs.pr_title }}",
|
||||
"pr_state": "${{ steps.pr_details.outputs.pr_state }}",
|
||||
"repo": "${{ github.repository }}"
|
||||
}
|
||||
|
||||
- name: Post deploy success comment
|
||||
if: steps.check_status.outputs.should_deploy == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `🚀 **Deploying PR #${{ steps.pr_details.outputs.pr_number }}** to development environment...`
|
||||
});
|
||||
|
||||
- name: Dispatch Undeploy Event (from comment)
|
||||
if: steps.check_status.outputs.should_undeploy == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.DISPATCH_TOKEN }}
|
||||
repository: Significant-Gravitas/AutoGPT_cloud_infrastructure
|
||||
event-type: pr-event
|
||||
client-payload: |
|
||||
{
|
||||
"action": "undeploy",
|
||||
"pr_number": "${{ steps.pr_details.outputs.pr_number }}",
|
||||
"pr_title": "${{ steps.pr_details.outputs.pr_title }}",
|
||||
"pr_state": "${{ steps.pr_details.outputs.pr_state }}",
|
||||
"repo": "${{ github.repository }}"
|
||||
}
|
||||
|
||||
- name: Post undeploy success comment
|
||||
if: steps.check_status.outputs.should_undeploy == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `🗑️ **Undeploying PR #${{ steps.pr_details.outputs.pr_number }}** from development environment...`
|
||||
});
|
||||
|
||||
- name: Check deployment status on PR close
|
||||
id: check_pr_close
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
|
||||
let lastDeployIndex = -1;
|
||||
let lastUndeployIndex = -1;
|
||||
|
||||
comments.data.forEach((comment, index) => {
|
||||
if (comment.body.trim() === '!deploy') {
|
||||
lastDeployIndex = index;
|
||||
} else if (comment.body.trim() === '!undeploy') {
|
||||
lastUndeployIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
// Should undeploy if there's a !deploy without a subsequent !undeploy
|
||||
const shouldUndeploy = lastDeployIndex !== -1 && lastDeployIndex > lastUndeployIndex;
|
||||
core.setOutput('should_undeploy', shouldUndeploy);
|
||||
|
||||
- name: Dispatch Undeploy Event (PR closed with active deployment)
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'closed' &&
|
||||
steps.check_pr_close.outputs.should_undeploy == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.DISPATCH_TOKEN }}
|
||||
repository: Significant-Gravitas/AutoGPT_cloud_infrastructure
|
||||
event-type: pr-event
|
||||
client-payload: |
|
||||
{
|
||||
"action": "undeploy",
|
||||
"pr_number": "${{ github.event.pull_request.number }}",
|
||||
"pr_title": "${{ github.event.pull_request.title }}",
|
||||
"pr_state": "${{ github.event.pull_request.state }}",
|
||||
"repo": "${{ github.repository }}"
|
||||
}
|
||||
|
||||
- name: Post PR close undeploy comment
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'closed' &&
|
||||
steps.check_pr_close.outputs.should_undeploy == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `🧹 **Auto-undeploying**: PR closed with active deployment. Cleaning up development environment for PR #${{ github.event.pull_request.number }}.`
|
||||
});
|
||||
57
.github/workflows/platform-dev-deploy.yml
vendored
Normal file
57
.github/workflows/platform-dev-deploy.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Dev Deploy PR Event Dispatcher
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, closed]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if should dispatch
|
||||
id: check
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.body == '!deploy' &&
|
||||
(
|
||||
github.event.comment.user.login == github.event.issue.user.login ||
|
||||
github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'
|
||||
)
|
||||
run: |
|
||||
echo "should_dispatch=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Dispatch PR Event
|
||||
if: steps.check.outputs.should_dispatch == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.DISPATCH_TOKEN }}
|
||||
repository: Significant-Gravitas/AutoGPT_cloud_infrastructure
|
||||
event-type: pr-event
|
||||
client-payload: |
|
||||
{
|
||||
"action": "deploy",
|
||||
"pr_number": "${{ github.event.pull_request.number }}",
|
||||
"pr_title": "${{ github.event.pull_request.title }}",
|
||||
"pr_state": "${{ github.event.pull_request.state }}",
|
||||
"repo": "${{ github.repository }}"
|
||||
}
|
||||
|
||||
- name: Dispatch PR Closure Event
|
||||
if: github.event.action == 'closed' && contains(github.event.pull_request.comments.*.body, '!deploy')
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.DISPATCH_TOKEN }}
|
||||
repository: Significant-Gravitas/AutoGPT_cloud_infrastructure
|
||||
event-type: pr-event
|
||||
client-payload: |
|
||||
{
|
||||
"action": "undeploy",
|
||||
"pr_number": "${{ github.event.pull_request.number }}",
|
||||
"pr_title": "${{ github.event.pull_request.title }}",
|
||||
"pr_state": "${{ github.event.pull_request.state }}",
|
||||
"repo": "${{ github.repository }}"
|
||||
}
|
||||
@@ -17,7 +17,7 @@ repos:
|
||||
name: Detect secrets
|
||||
description: Detects high entropy strings that are likely to be passwords.
|
||||
files: ^autogpt_platform/
|
||||
stages: [push]
|
||||
stages: [pre-push]
|
||||
|
||||
- repo: local
|
||||
# For proper type checking, all dependencies need to be up-to-date.
|
||||
@@ -241,38 +241,38 @@ repos:
|
||||
language: system
|
||||
pass_filenames: false
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: Run tests - AutoGPT Platform - Backend
|
||||
alias: pytest-platform-backend
|
||||
entry: bash -c 'cd autogpt_platform/backend && poetry run pytest'
|
||||
# include autogpt_libs source (since it's a path dependency) but exclude *_test.py files:
|
||||
files: ^autogpt_platform/(backend/((backend|test)/|poetry\.lock$)|autogpt_libs/(autogpt_libs/.*(?<!_test)\.py|poetry\.lock)$)
|
||||
language: system
|
||||
pass_filenames: false
|
||||
# - repo: local
|
||||
# hooks:
|
||||
# - id: pytest
|
||||
# name: Run tests - AutoGPT Platform - Backend
|
||||
# alias: pytest-platform-backend
|
||||
# entry: bash -c 'cd autogpt_platform/backend && poetry run pytest'
|
||||
# # include autogpt_libs source (since it's a path dependency) but exclude *_test.py files:
|
||||
# files: ^autogpt_platform/(backend/((backend|test)/|poetry\.lock$)|autogpt_libs/(autogpt_libs/.*(?<!_test)\.py|poetry\.lock)$)
|
||||
# language: system
|
||||
# pass_filenames: false
|
||||
|
||||
- id: pytest
|
||||
name: Run tests - Classic - AutoGPT (excl. slow tests)
|
||||
alias: pytest-classic-autogpt
|
||||
entry: bash -c 'cd classic/original_autogpt && poetry run pytest --cov=autogpt -m "not slow" tests/unit tests/integration'
|
||||
# include forge source (since it's a path dependency) but exclude *_test.py files:
|
||||
files: ^(classic/original_autogpt/((autogpt|tests)/|poetry\.lock$)|classic/forge/(forge/.*(?<!_test)\.py|poetry\.lock)$)
|
||||
language: system
|
||||
pass_filenames: false
|
||||
# - id: pytest
|
||||
# name: Run tests - Classic - AutoGPT (excl. slow tests)
|
||||
# alias: pytest-classic-autogpt
|
||||
# entry: bash -c 'cd classic/original_autogpt && poetry run pytest --cov=autogpt -m "not slow" tests/unit tests/integration'
|
||||
# # include forge source (since it's a path dependency) but exclude *_test.py files:
|
||||
# files: ^(classic/original_autogpt/((autogpt|tests)/|poetry\.lock$)|classic/forge/(forge/.*(?<!_test)\.py|poetry\.lock)$)
|
||||
# language: system
|
||||
# pass_filenames: false
|
||||
|
||||
- id: pytest
|
||||
name: Run tests - Classic - Forge (excl. slow tests)
|
||||
alias: pytest-classic-forge
|
||||
entry: bash -c 'cd classic/forge && poetry run pytest --cov=forge -m "not slow"'
|
||||
files: ^classic/forge/(forge/|tests/|poetry\.lock$)
|
||||
language: system
|
||||
pass_filenames: false
|
||||
# - id: pytest
|
||||
# name: Run tests - Classic - Forge (excl. slow tests)
|
||||
# alias: pytest-classic-forge
|
||||
# entry: bash -c 'cd classic/forge && poetry run pytest --cov=forge -m "not slow"'
|
||||
# files: ^classic/forge/(forge/|tests/|poetry\.lock$)
|
||||
# language: system
|
||||
# pass_filenames: false
|
||||
|
||||
- id: pytest
|
||||
name: Run tests - Classic - Benchmark
|
||||
alias: pytest-classic-benchmark
|
||||
entry: bash -c 'cd classic/benchmark && poetry run pytest --cov=benchmark'
|
||||
files: ^classic/benchmark/(agbenchmark/|tests/|poetry\.lock$)
|
||||
language: system
|
||||
pass_filenames: false
|
||||
# - id: pytest
|
||||
# name: Run tests - Classic - Benchmark
|
||||
# alias: pytest-classic-benchmark
|
||||
# entry: bash -c 'cd classic/benchmark && poetry run pytest --cov=benchmark'
|
||||
# files: ^classic/benchmark/(agbenchmark/|tests/|poetry\.lock$)
|
||||
# language: system
|
||||
# pass_filenames: false
|
||||
|
||||
50
AGENTS.md
Normal file
50
AGENTS.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# AutoGPT Platform Contribution Guide
|
||||
|
||||
This guide provides context for Codex when updating the **autogpt_platform** folder.
|
||||
|
||||
## Directory overview
|
||||
- `autogpt_platform/backend` – FastAPI based backend service.
|
||||
- `autogpt_platform/autogpt_libs` – Shared Python libraries.
|
||||
- `autogpt_platform/frontend` – Next.js + Typescript frontend.
|
||||
- `autogpt_platform/docker-compose.yml` – development stack.
|
||||
|
||||
See `docs/content/platform/getting-started.md` for setup instructions.
|
||||
|
||||
## Code style
|
||||
- Format Python code with `poetry run format`.
|
||||
- Format frontend code using `yarn format`.
|
||||
|
||||
## Testing
|
||||
- Backend: `poetry run test` (runs pytest with a docker based postgres + prisma).
|
||||
- Frontend: `yarn test` or `yarn test-ui` for Playwright tests. See `docs/content/platform/contributing/tests.md` for tips.
|
||||
|
||||
Always run the relevant linters and tests before committing.
|
||||
Use conventional commit messages for all commits (e.g. `feat(backend): add API`).
|
||||
Types:
|
||||
- feat
|
||||
- fix
|
||||
- refactor
|
||||
- ci
|
||||
- dx (developer experience)
|
||||
Scopes:
|
||||
- platform
|
||||
- platform/library
|
||||
- platform/marketplace
|
||||
- backend
|
||||
- backend/executor
|
||||
- frontend
|
||||
- frontend/library
|
||||
- frontend/marketplace
|
||||
- blocks
|
||||
|
||||
## Pull requests
|
||||
- Use the template in `.github/PULL_REQUEST_TEMPLATE.md`.
|
||||
- Rely on the pre-commit checks for linting and formatting
|
||||
- Fill out the **Changes** section and the checklist.
|
||||
- Use conventional commit titles with a scope (e.g. `feat(frontend): add feature`).
|
||||
- Keep out-of-scope changes under 20% of the PR.
|
||||
- Ensure PR descriptions are complete.
|
||||
- For changes touching `data/*.py`, validate user ID checks or explain why not needed.
|
||||
- If adding protected frontend routes, update `frontend/lib/supabase/middleware.ts`.
|
||||
- Use the linear ticket branch structure if given codex/open-1668-resume-dropped-runs
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# AutoGPT Libs
|
||||
|
||||
This is a new project to store shared functionality across different services in NextGen AutoGPT (e.g. authentication)
|
||||
This is a new project to store shared functionality across different services in the AutoGPT Platform (e.g. authentication)
|
||||
|
||||
@@ -85,4 +85,3 @@ class ExaContentsBlock(Block):
|
||||
yield "results", data.get("results", [])
|
||||
except Exception as e:
|
||||
yield "error", str(e)
|
||||
yield "results", []
|
||||
|
||||
@@ -78,6 +78,9 @@ class ExaSearchBlock(Block):
|
||||
description="List of search results",
|
||||
default_factory=list,
|
||||
)
|
||||
error: str = SchemaField(
|
||||
description="Error message if the request failed",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
@@ -140,4 +143,3 @@ class ExaSearchBlock(Block):
|
||||
yield "results", data.get("results", [])
|
||||
except Exception as e:
|
||||
yield "error", str(e)
|
||||
yield "results", []
|
||||
|
||||
@@ -67,6 +67,7 @@ class ExaFindSimilarBlock(Block):
|
||||
description="List of similar documents with title, URL, published date, author, and score",
|
||||
default_factory=list,
|
||||
)
|
||||
error: str = SchemaField(description="Error message if the request failed")
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
@@ -125,4 +126,3 @@ class ExaFindSimilarBlock(Block):
|
||||
yield "results", data.get("results", [])
|
||||
except Exception as e:
|
||||
yield "error", str(e)
|
||||
yield "results", []
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
from typing import overload
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from backend.blocks.github._auth import (
|
||||
GithubCredentials,
|
||||
GithubFineGrainedAPICredentials,
|
||||
)
|
||||
from backend.util.request import Requests
|
||||
from backend.util.request import URL, Requests
|
||||
|
||||
|
||||
def _convert_to_api_url(url: str) -> str:
|
||||
@overload
|
||||
def _convert_to_api_url(url: str) -> str: ...
|
||||
|
||||
|
||||
@overload
|
||||
def _convert_to_api_url(url: URL) -> URL: ...
|
||||
|
||||
|
||||
def _convert_to_api_url(url: str | URL) -> str | URL:
|
||||
"""
|
||||
Converts a standard GitHub URL to the corresponding GitHub API URL.
|
||||
Handles repository URLs, issue URLs, pull request URLs, and more.
|
||||
"""
|
||||
parsed_url = urlparse(url)
|
||||
path_parts = parsed_url.path.strip("/").split("/")
|
||||
if url_as_str := isinstance(url, str):
|
||||
url = urlparse(url)
|
||||
|
||||
path_parts = url.path.strip("/").split("/")
|
||||
|
||||
if len(path_parts) >= 2:
|
||||
owner, repo = path_parts[0], path_parts[1]
|
||||
@@ -28,7 +39,7 @@ def _convert_to_api_url(url: str) -> str:
|
||||
else:
|
||||
raise ValueError("Invalid GitHub URL format.")
|
||||
|
||||
return api_url
|
||||
return api_url if url_as_str else urlparse(api_url)
|
||||
|
||||
|
||||
def _get_headers(credentials: GithubCredentials) -> dict[str, str]:
|
||||
|
||||
@@ -101,6 +101,8 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
||||
GPT4_TURBO = "gpt-4-turbo"
|
||||
GPT3_5_TURBO = "gpt-3.5-turbo"
|
||||
# Anthropic models
|
||||
CLAUDE_4_OPUS = "claude-opus-4-20250514"
|
||||
CLAUDE_4_SONNET = "claude-sonnet-4-20250514"
|
||||
CLAUDE_3_7_SONNET = "claude-3-7-sonnet-20250219"
|
||||
CLAUDE_3_5_SONNET = "claude-3-5-sonnet-latest"
|
||||
CLAUDE_3_5_HAIKU = "claude-3-5-haiku-latest"
|
||||
@@ -184,6 +186,12 @@ MODEL_METADATA = {
|
||||
), # gpt-4-turbo-2024-04-09
|
||||
LlmModel.GPT3_5_TURBO: ModelMetadata("openai", 16385, 4096), # gpt-3.5-turbo-0125
|
||||
# https://docs.anthropic.com/en/docs/about-claude/models
|
||||
LlmModel.CLAUDE_4_OPUS: ModelMetadata(
|
||||
"anthropic", 200000, 8192
|
||||
), # claude-4-opus-20250514
|
||||
LlmModel.CLAUDE_4_SONNET: ModelMetadata(
|
||||
"anthropic", 200000, 8192
|
||||
), # claude-4-sonnet-20250514
|
||||
LlmModel.CLAUDE_3_7_SONNET: ModelMetadata(
|
||||
"anthropic", 200000, 8192
|
||||
), # claude-3-7-sonnet-20250219
|
||||
|
||||
@@ -124,8 +124,10 @@ class AddMemoryBlock(Block, Mem0Base):
|
||||
|
||||
if isinstance(input_data.content, Conversation):
|
||||
messages = input_data.content.messages
|
||||
elif isinstance(input_data.content, Content):
|
||||
messages = [{"role": "user", "content": input_data.content.content}]
|
||||
else:
|
||||
messages = [{"role": "user", "content": input_data.content}]
|
||||
messages = [{"role": "user", "content": str(input_data.content)}]
|
||||
|
||||
params = {
|
||||
"user_id": user_id,
|
||||
@@ -152,7 +154,7 @@ class AddMemoryBlock(Block, Mem0Base):
|
||||
yield "action", "NO_CHANGE"
|
||||
|
||||
except Exception as e:
|
||||
yield "error", str(object=e)
|
||||
yield "error", str(e)
|
||||
|
||||
|
||||
class SearchMemoryBlock(Block, Mem0Base):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import functools
|
||||
import inspect
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
@@ -8,6 +9,7 @@ from typing import (
|
||||
Generator,
|
||||
Generic,
|
||||
Optional,
|
||||
Sequence,
|
||||
Type,
|
||||
TypeVar,
|
||||
cast,
|
||||
@@ -523,3 +525,21 @@ async def initialize_blocks() -> None:
|
||||
def get_block(block_id: str) -> Block[BlockSchema, BlockSchema] | None:
|
||||
cls = get_blocks().get(block_id)
|
||||
return cls() if cls else None
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_webhook_block_ids() -> Sequence[str]:
|
||||
return [
|
||||
id
|
||||
for id, B in get_blocks().items()
|
||||
if B().block_type in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL)
|
||||
]
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_io_block_ids() -> Sequence[str]:
|
||||
return [
|
||||
id
|
||||
for id, B in get_blocks().items()
|
||||
if B().block_type in (BlockType.INPUT, BlockType.OUTPUT)
|
||||
]
|
||||
|
||||
@@ -47,6 +47,8 @@ MODEL_COST: dict[LlmModel, int] = {
|
||||
LlmModel.GPT4O: 3,
|
||||
LlmModel.GPT4_TURBO: 10,
|
||||
LlmModel.GPT3_5_TURBO: 1,
|
||||
LlmModel.CLAUDE_4_OPUS: 21,
|
||||
LlmModel.CLAUDE_4_SONNET: 5,
|
||||
LlmModel.CLAUDE_3_7_SONNET: 5,
|
||||
LlmModel.CLAUDE_3_5_SONNET: 4,
|
||||
LlmModel.CLAUDE_3_5_HAIKU: 1, # $0.80 / $4.00
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from multiprocessing import Manager
|
||||
from typing import (
|
||||
@@ -24,6 +24,7 @@ from prisma.models import (
|
||||
)
|
||||
from prisma.types import (
|
||||
AgentGraphExecutionCreateInput,
|
||||
AgentGraphExecutionUpdateManyMutationInput,
|
||||
AgentGraphExecutionWhereInput,
|
||||
AgentNodeExecutionCreateInput,
|
||||
AgentNodeExecutionInputOutputCreateInput,
|
||||
@@ -37,12 +38,19 @@ from backend.server.v2.store.exceptions import DatabaseError
|
||||
from backend.util import type as type_utils
|
||||
from backend.util.settings import Config
|
||||
|
||||
from .block import BlockInput, BlockType, CompletedBlockOutput, get_block
|
||||
from .block import (
|
||||
BlockInput,
|
||||
BlockType,
|
||||
CompletedBlockOutput,
|
||||
get_block,
|
||||
get_io_block_ids,
|
||||
get_webhook_block_ids,
|
||||
)
|
||||
from .db import BaseDbModel
|
||||
from .includes import (
|
||||
EXECUTION_RESULT_INCLUDE,
|
||||
GRAPH_EXECUTION_INCLUDE,
|
||||
GRAPH_EXECUTION_INCLUDE_WITH_NODES,
|
||||
graph_execution_include,
|
||||
)
|
||||
from .model import CredentialsMetaInput, GraphExecutionStats, NodeExecutionStats
|
||||
from .queue import AsyncRedisEventBus, RedisEventBus
|
||||
@@ -410,7 +418,9 @@ async def get_graph_execution(
|
||||
include=(
|
||||
GRAPH_EXECUTION_INCLUDE_WITH_NODES
|
||||
if include_node_executions
|
||||
else GRAPH_EXECUTION_INCLUDE
|
||||
else graph_execution_include(
|
||||
[*get_io_block_ids(), *get_webhook_block_ids()]
|
||||
)
|
||||
),
|
||||
)
|
||||
if not execution:
|
||||
@@ -488,10 +498,15 @@ async def upsert_execution_input(
|
||||
dict[str, Any]: Node input data; key is the input name, value is the input data.
|
||||
"""
|
||||
existing_exec_query_filter: AgentNodeExecutionWhereInput = {
|
||||
"agentNodeId": node_id,
|
||||
"agentGraphExecutionId": graph_exec_id,
|
||||
"agentNodeId": node_id,
|
||||
"executionStatus": ExecutionStatus.INCOMPLETE,
|
||||
"Input": {"every": {"name": {"not": input_name}}},
|
||||
"Input": {
|
||||
"none": {
|
||||
"name": input_name,
|
||||
"time": {"gte": datetime.now(tz=timezone.utc) - timedelta(days=1)},
|
||||
}
|
||||
},
|
||||
}
|
||||
if node_exec_id:
|
||||
existing_exec_query_filter["id"] = node_exec_id
|
||||
@@ -562,7 +577,9 @@ async def update_graph_execution_start_time(
|
||||
"executionStatus": ExecutionStatus.RUNNING,
|
||||
"startedAt": datetime.now(tz=timezone.utc),
|
||||
},
|
||||
include=GRAPH_EXECUTION_INCLUDE,
|
||||
include=graph_execution_include(
|
||||
[*get_io_block_ids(), *get_webhook_block_ids()]
|
||||
),
|
||||
)
|
||||
return GraphExecution.from_db(res) if res else None
|
||||
|
||||
@@ -572,9 +589,15 @@ async def update_graph_execution_stats(
|
||||
status: ExecutionStatus,
|
||||
stats: GraphExecutionStats | None = None,
|
||||
) -> GraphExecution | None:
|
||||
data = stats.model_dump() if stats else {}
|
||||
if isinstance(data.get("error"), Exception):
|
||||
data["error"] = str(data["error"])
|
||||
update_data: AgentGraphExecutionUpdateManyMutationInput = {
|
||||
"executionStatus": status
|
||||
}
|
||||
|
||||
if stats:
|
||||
stats_dict = stats.model_dump()
|
||||
if isinstance(stats_dict.get("error"), Exception):
|
||||
stats_dict["error"] = str(stats_dict["error"])
|
||||
update_data["stats"] = Json(stats_dict)
|
||||
|
||||
updated_count = await AgentGraphExecution.prisma().update_many(
|
||||
where={
|
||||
@@ -584,17 +607,16 @@ async def update_graph_execution_stats(
|
||||
{"executionStatus": ExecutionStatus.QUEUED},
|
||||
],
|
||||
},
|
||||
data={
|
||||
"executionStatus": status,
|
||||
"stats": Json(data),
|
||||
},
|
||||
data=update_data,
|
||||
)
|
||||
if updated_count == 0:
|
||||
return None
|
||||
|
||||
graph_exec = await AgentGraphExecution.prisma().find_unique_or_raise(
|
||||
where={"id": graph_exec_id},
|
||||
include=GRAPH_EXECUTION_INCLUDE,
|
||||
include=graph_execution_include(
|
||||
[*get_io_block_ids(), *get_webhook_block_ids()]
|
||||
),
|
||||
)
|
||||
return GraphExecution.from_db(graph_exec)
|
||||
|
||||
@@ -711,9 +733,15 @@ async def get_latest_node_execution(
|
||||
) -> NodeExecutionResult | None:
|
||||
execution = await AgentNodeExecution.prisma().find_first(
|
||||
where={
|
||||
"agentNodeId": node_id,
|
||||
"agentGraphExecutionId": graph_eid,
|
||||
"NOT": [{"executionStatus": ExecutionStatus.INCOMPLETE}],
|
||||
"agentNodeId": node_id,
|
||||
"OR": [
|
||||
{"executionStatus": ExecutionStatus.QUEUED},
|
||||
{"executionStatus": ExecutionStatus.RUNNING},
|
||||
{"executionStatus": ExecutionStatus.COMPLETED},
|
||||
{"executionStatus": ExecutionStatus.TERMINATED},
|
||||
{"executionStatus": ExecutionStatus.FAILED},
|
||||
],
|
||||
},
|
||||
order=[
|
||||
{"queuedTime": "desc"},
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from typing import cast
|
||||
from typing import Sequence, cast
|
||||
|
||||
import prisma.enums
|
||||
import prisma.types
|
||||
|
||||
from backend.blocks.io import IO_BLOCK_IDs
|
||||
|
||||
AGENT_NODE_INCLUDE: prisma.types.AgentNodeInclude = {
|
||||
"Input": True,
|
||||
"Output": True,
|
||||
@@ -42,18 +40,26 @@ GRAPH_EXECUTION_INCLUDE_WITH_NODES: prisma.types.AgentGraphExecutionInclude = {
|
||||
}
|
||||
}
|
||||
|
||||
GRAPH_EXECUTION_INCLUDE: prisma.types.AgentGraphExecutionInclude = {
|
||||
"NodeExecutions": {
|
||||
**cast(
|
||||
prisma.types.FindManyAgentNodeExecutionArgsFromAgentGraphExecution,
|
||||
GRAPH_EXECUTION_INCLUDE_WITH_NODES["NodeExecutions"],
|
||||
),
|
||||
"where": {
|
||||
"Node": {"is": {"AgentBlock": {"is": {"id": {"in": IO_BLOCK_IDs}}}}},
|
||||
"NOT": [{"executionStatus": prisma.enums.AgentExecutionStatus.INCOMPLETE}],
|
||||
},
|
||||
|
||||
def graph_execution_include(
|
||||
include_block_ids: Sequence[str],
|
||||
) -> prisma.types.AgentGraphExecutionInclude:
|
||||
return {
|
||||
"NodeExecutions": {
|
||||
**cast(
|
||||
prisma.types.FindManyAgentNodeExecutionArgsFromAgentGraphExecution,
|
||||
GRAPH_EXECUTION_INCLUDE_WITH_NODES["NodeExecutions"], # type: ignore
|
||||
),
|
||||
"where": {
|
||||
"Node": {
|
||||
"is": {"AgentBlock": {"is": {"id": {"in": include_block_ids}}}}
|
||||
},
|
||||
"NOT": [
|
||||
{"executionStatus": prisma.enums.AgentExecutionStatus.INCOMPLETE}
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
INTEGRATION_WEBHOOK_INCLUDE: prisma.types.IntegrationWebhookInclude = {
|
||||
|
||||
@@ -189,7 +189,7 @@ def SchemaField(
|
||||
class _BaseCredentials(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
provider: str
|
||||
title: Optional[str]
|
||||
title: Optional[str] = None
|
||||
|
||||
@field_serializer("*")
|
||||
def dump_secret_strings(value: Any, _info):
|
||||
@@ -200,13 +200,13 @@ class _BaseCredentials(BaseModel):
|
||||
|
||||
class OAuth2Credentials(_BaseCredentials):
|
||||
type: Literal["oauth2"] = "oauth2"
|
||||
username: Optional[str]
|
||||
username: Optional[str] = None
|
||||
"""Username of the third-party service user that these credentials belong to"""
|
||||
access_token: SecretStr
|
||||
access_token_expires_at: Optional[int]
|
||||
access_token_expires_at: Optional[int] = None
|
||||
"""Unix timestamp (seconds) indicating when the access token expires (if at all)"""
|
||||
refresh_token: Optional[SecretStr]
|
||||
refresh_token_expires_at: Optional[int]
|
||||
refresh_token: Optional[SecretStr] = None
|
||||
refresh_token_expires_at: Optional[int] = None
|
||||
"""Unix timestamp (seconds) indicating when the refresh token expires (if at all)"""
|
||||
scopes: list[str]
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -39,6 +39,7 @@ class UserOnboardingUpdate(pydantic.BaseModel):
|
||||
selectedStoreListingVersionId: Optional[str] = None
|
||||
agentInput: Optional[dict[str, Any]] = None
|
||||
onboardingAgentExecutionId: Optional[str] = None
|
||||
agentRuns: Optional[int] = None
|
||||
|
||||
|
||||
async def get_user_onboarding(user_id: str):
|
||||
@@ -57,7 +58,7 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
|
||||
update["completedSteps"] = list(set(data.completedSteps))
|
||||
for step in (
|
||||
OnboardingStep.AGENT_NEW_RUN,
|
||||
OnboardingStep.GET_RESULTS,
|
||||
OnboardingStep.RUN_AGENTS,
|
||||
OnboardingStep.MARKETPLACE_ADD_AGENT,
|
||||
OnboardingStep.MARKETPLACE_RUN_AGENT,
|
||||
OnboardingStep.BUILDER_SAVE_AGENT,
|
||||
@@ -81,6 +82,8 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
|
||||
update["agentInput"] = Json(data.agentInput)
|
||||
if data.onboardingAgentExecutionId is not None:
|
||||
update["onboardingAgentExecutionId"] = data.onboardingAgentExecutionId
|
||||
if data.agentRuns is not None:
|
||||
update["agentRuns"] = data.agentRuns
|
||||
|
||||
return await UserOnboarding.prisma().upsert(
|
||||
where={"userId": user_id},
|
||||
@@ -97,9 +100,10 @@ async def reward_user(user_id: str, step: OnboardingStep):
|
||||
match step:
|
||||
# Reward user when they clicked New Run during onboarding
|
||||
# This is because they need credits before scheduling a run (next step)
|
||||
# This is seen as a reward for the GET_RESULTS step in the wallet
|
||||
case OnboardingStep.AGENT_NEW_RUN:
|
||||
reward = 300
|
||||
case OnboardingStep.GET_RESULTS:
|
||||
case OnboardingStep.RUN_AGENTS:
|
||||
reward = 300
|
||||
case OnboardingStep.MARKETPLACE_ADD_AGENT:
|
||||
reward = 100
|
||||
|
||||
@@ -124,7 +124,7 @@ async def get_user_integrations(user_id: str) -> UserIntegrations:
|
||||
|
||||
|
||||
async def update_user_integrations(user_id: str, data: UserIntegrations):
|
||||
encrypted_data = JSONCryptor().encrypt(data.model_dump())
|
||||
encrypted_data = JSONCryptor().encrypt(data.model_dump(exclude_none=True))
|
||||
await User.prisma().update(
|
||||
where={"id": user_id},
|
||||
data={"integrations": encrypted_data},
|
||||
|
||||
@@ -67,7 +67,7 @@ from backend.util.decorator import error_logged, time_measured
|
||||
from backend.util.file import clean_exec_files
|
||||
from backend.util.logging import TruncatedLogger, configure_logging
|
||||
from backend.util.process import AppProcess, set_service_name
|
||||
from backend.util.retry import func_retry
|
||||
from backend.util.retry import continuous_retry, func_retry
|
||||
from backend.util.service import get_service_client
|
||||
from backend.util.settings import Settings
|
||||
|
||||
@@ -938,9 +938,6 @@ class ExecutionManager(AppProcess):
|
||||
self.pool_size = settings.config.num_graph_workers
|
||||
self.running = True
|
||||
self.active_graph_runs: dict[str, tuple[Future, threading.Event]] = {}
|
||||
atexit.register(self._on_cleanup)
|
||||
signal.signal(signal.SIGTERM, lambda sig, frame: self._on_sigterm())
|
||||
signal.signal(signal.SIGINT, lambda sig, frame: self._on_sigterm())
|
||||
|
||||
def run(self):
|
||||
pool_size_gauge.set(self.pool_size)
|
||||
@@ -966,22 +963,29 @@ class ExecutionManager(AppProcess):
|
||||
logger.info(f"[{self.service_name}] ⏳ Connecting to Redis...")
|
||||
redis.connect()
|
||||
|
||||
threading.Thread(
|
||||
target=lambda: self._consume_execution_cancel(),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
self._consume_execution_run()
|
||||
|
||||
@continuous_retry()
|
||||
def _consume_execution_cancel(self):
|
||||
cancel_client = SyncRabbitMQ(create_execution_queue_config())
|
||||
cancel_client.connect()
|
||||
cancel_channel = cancel_client.get_channel()
|
||||
logger.info(f"[{self.service_name}] ⏳ Starting cancel message consumer...")
|
||||
threading.Thread(
|
||||
target=lambda: (
|
||||
cancel_channel.basic_consume(
|
||||
queue=GRAPH_EXECUTION_CANCEL_QUEUE_NAME,
|
||||
on_message_callback=self._handle_cancel_message,
|
||||
auto_ack=True,
|
||||
),
|
||||
cancel_channel.start_consuming(),
|
||||
),
|
||||
daemon=True,
|
||||
).start()
|
||||
cancel_channel.basic_consume(
|
||||
queue=GRAPH_EXECUTION_CANCEL_QUEUE_NAME,
|
||||
on_message_callback=self._handle_cancel_message,
|
||||
auto_ack=True,
|
||||
)
|
||||
cancel_channel.start_consuming()
|
||||
raise RuntimeError(f"❌ cancel message consumer is stopped: {cancel_channel}")
|
||||
|
||||
@continuous_retry()
|
||||
def _consume_execution_run(self):
|
||||
run_client = SyncRabbitMQ(create_execution_queue_config())
|
||||
run_client.connect()
|
||||
run_channel = run_client.get_channel()
|
||||
@@ -993,6 +997,7 @@ class ExecutionManager(AppProcess):
|
||||
)
|
||||
logger.info(f"[{self.service_name}] ⏳ Starting to consume run messages...")
|
||||
run_channel.start_consuming()
|
||||
raise RuntimeError(f"❌ run message consumer is stopped: {run_channel}")
|
||||
|
||||
def _handle_cancel_message(
|
||||
self,
|
||||
@@ -1091,10 +1096,6 @@ class ExecutionManager(AppProcess):
|
||||
super().cleanup()
|
||||
self._on_cleanup()
|
||||
|
||||
def _on_sigterm(self):
|
||||
llprint(f"[{self.service_name}] ⚠️ GraphExec SIGTERM received")
|
||||
self._on_cleanup(log=llprint)
|
||||
|
||||
def _on_cleanup(self, log=logger.info):
|
||||
prefix = f"[{self.service_name}][on_graph_executor_stop {os.getpid()}]"
|
||||
log(f"{prefix} ⏳ Shutting down service loop...")
|
||||
@@ -1111,7 +1112,7 @@ class ExecutionManager(AppProcess):
|
||||
redis.disconnect()
|
||||
|
||||
log(f"{prefix} ✅ Finished GraphExec cleanup")
|
||||
exit(0)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# ------- UTILITIES ------- #
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from autogpt_libs.utils.synchronize import RedisKeyedMutex
|
||||
from redis.lock import Lock as RedisLock
|
||||
|
||||
from backend.data import redis
|
||||
from backend.data.model import Credentials
|
||||
from backend.data.model import Credentials, OAuth2Credentials
|
||||
from backend.integrations.credentials_store import IntegrationCredentialsStore
|
||||
from backend.integrations.oauth import HANDLERS_BY_NAME
|
||||
from backend.integrations.providers import ProviderName
|
||||
@@ -78,25 +78,7 @@ class IntegrationCredentialsManager:
|
||||
f"{datetime.fromtimestamp(credentials.access_token_expires_at)}; "
|
||||
f"current time is {datetime.now()}"
|
||||
)
|
||||
|
||||
with self._locked(user_id, credentials_id, "refresh"):
|
||||
oauth_handler = _get_provider_oauth_handler(credentials.provider)
|
||||
if oauth_handler.needs_refresh(credentials):
|
||||
logger.debug(
|
||||
f"Refreshing '{credentials.provider}' "
|
||||
f"credentials #{credentials.id}"
|
||||
)
|
||||
_lock = None
|
||||
if lock:
|
||||
# Wait until the credentials are no longer in use anywhere
|
||||
_lock = self._acquire_lock(user_id, credentials_id)
|
||||
|
||||
fresh_credentials = oauth_handler.refresh_tokens(credentials)
|
||||
self.store.update_creds(user_id, fresh_credentials)
|
||||
if _lock and _lock.locked() and _lock.owned():
|
||||
_lock.release()
|
||||
|
||||
credentials = fresh_credentials
|
||||
credentials = self.refresh_if_needed(user_id, credentials, lock)
|
||||
else:
|
||||
logger.debug(f"Credentials #{credentials.id} never expire")
|
||||
|
||||
@@ -121,6 +103,50 @@ class IntegrationCredentialsManager:
|
||||
)
|
||||
return credentials, lock
|
||||
|
||||
def cached_getter(self, user_id: str) -> Callable[[str], "Credentials | None"]:
|
||||
all_credentials = None
|
||||
|
||||
def get_credentials(creds_id: str) -> "Credentials | None":
|
||||
nonlocal all_credentials
|
||||
if not all_credentials:
|
||||
# Fetch credentials on first necessity
|
||||
all_credentials = self.store.get_all_creds(user_id)
|
||||
|
||||
credential = next((c for c in all_credentials if c.id == creds_id), None)
|
||||
if not credential:
|
||||
return None
|
||||
if credential.type != "oauth2" or not credential.access_token_expires_at:
|
||||
# Credential doesn't expire
|
||||
return credential
|
||||
|
||||
# Credential is OAuth2 credential and has expiration timestamp
|
||||
return self.refresh_if_needed(user_id, credential)
|
||||
|
||||
return get_credentials
|
||||
|
||||
def refresh_if_needed(
|
||||
self, user_id: str, credentials: OAuth2Credentials, lock: bool = True
|
||||
) -> OAuth2Credentials:
|
||||
with self._locked(user_id, credentials.id, "refresh"):
|
||||
oauth_handler = _get_provider_oauth_handler(credentials.provider)
|
||||
if oauth_handler.needs_refresh(credentials):
|
||||
logger.debug(
|
||||
f"Refreshing '{credentials.provider}' "
|
||||
f"credentials #{credentials.id}"
|
||||
)
|
||||
_lock = None
|
||||
if lock:
|
||||
# Wait until the credentials are no longer in use anywhere
|
||||
_lock = self._acquire_lock(user_id, credentials.id)
|
||||
|
||||
fresh_credentials = oauth_handler.refresh_tokens(credentials)
|
||||
self.store.update_creds(user_id, fresh_credentials)
|
||||
if _lock and _lock.locked() and _lock.owned():
|
||||
_lock.release()
|
||||
|
||||
credentials = fresh_credentials
|
||||
return credentials
|
||||
|
||||
def update(self, user_id: str, updated: Credentials) -> None:
|
||||
with self._locked(user_id, updated.id):
|
||||
self.store.update_creds(user_id, updated)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Callable, Optional, cast
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
|
||||
from backend.data.block import BlockSchema, BlockWebhookConfig
|
||||
from backend.data.graph import set_node_webhook
|
||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||
from backend.integrations.webhooks import get_webhook_manager, supports_webhooks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -12,21 +13,17 @@ if TYPE_CHECKING:
|
||||
from ._base import BaseWebhooksManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
credentials_manager = IntegrationCredentialsManager()
|
||||
|
||||
|
||||
async def on_graph_activate(
|
||||
graph: "GraphModel", get_credentials: Callable[[str], "Credentials | None"]
|
||||
):
|
||||
async def on_graph_activate(graph: "GraphModel", user_id: str):
|
||||
"""
|
||||
Hook to be called when a graph is activated/created.
|
||||
|
||||
⚠️ Assuming node entities are not re-used between graph versions, ⚠️
|
||||
this hook calls `on_node_activate` on all nodes in this graph.
|
||||
|
||||
Params:
|
||||
get_credentials: `credentials_id` -> Credentials
|
||||
"""
|
||||
# Compare nodes in new_graph_version with previous_graph_version
|
||||
get_credentials = credentials_manager.cached_getter(user_id)
|
||||
updated_nodes = []
|
||||
for new_node in graph.nodes:
|
||||
block_input_schema = cast(BlockSchema, new_node.block.input_schema)
|
||||
@@ -56,18 +53,14 @@ async def on_graph_activate(
|
||||
return graph
|
||||
|
||||
|
||||
async def on_graph_deactivate(
|
||||
graph: "GraphModel", get_credentials: Callable[[str], "Credentials | None"]
|
||||
):
|
||||
async def on_graph_deactivate(graph: "GraphModel", user_id: str):
|
||||
"""
|
||||
Hook to be called when a graph is deactivated/deleted.
|
||||
|
||||
⚠️ Assuming node entities are not re-used between graph versions, ⚠️
|
||||
this hook calls `on_node_deactivate` on all nodes in `graph`.
|
||||
|
||||
Params:
|
||||
get_credentials: `credentials_id` -> Credentials
|
||||
"""
|
||||
get_credentials = credentials_manager.cached_getter(user_id)
|
||||
updated_nodes = []
|
||||
for node in graph.nodes:
|
||||
block_input_schema = cast(BlockSchema, node.block.input_schema)
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Sequence
|
||||
from typing import Annotated, Any, Sequence
|
||||
|
||||
import pydantic
|
||||
import stripe
|
||||
@@ -60,7 +60,6 @@ from backend.data.user import (
|
||||
from backend.executor import scheduler
|
||||
from backend.executor import utils as execution_utils
|
||||
from backend.executor.utils import create_execution_queue_config
|
||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||
from backend.integrations.webhooks.graph_lifecycle_hooks import (
|
||||
on_graph_activate,
|
||||
on_graph_deactivate,
|
||||
@@ -78,13 +77,10 @@ from backend.server.utils import get_user_id
|
||||
from backend.util.service import get_service_client
|
||||
from backend.util.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.data.model import Credentials
|
||||
|
||||
|
||||
@thread_cached
|
||||
def execution_scheduler_client() -> scheduler.SchedulerClient:
|
||||
return get_service_client(scheduler.SchedulerClient)
|
||||
return get_service_client(scheduler.SchedulerClient, health_check=False)
|
||||
|
||||
|
||||
@thread_cached
|
||||
@@ -101,7 +97,6 @@ def execution_event_bus() -> AsyncRedisExecutionEventBus:
|
||||
|
||||
settings = Settings()
|
||||
logger = logging.getLogger(__name__)
|
||||
integration_creds_manager = IntegrationCredentialsManager()
|
||||
|
||||
_user_credit_model = get_user_credit_model()
|
||||
|
||||
@@ -466,10 +461,7 @@ async def create_new_graph(
|
||||
library_db.add_generated_agent_image(graph, library_agent.id)
|
||||
)
|
||||
|
||||
graph = await on_graph_activate(
|
||||
graph,
|
||||
get_credentials=lambda id: integration_creds_manager.get(user_id, id),
|
||||
)
|
||||
graph = await on_graph_activate(graph, user_id=user_id)
|
||||
return graph
|
||||
|
||||
|
||||
@@ -480,11 +472,7 @@ async def delete_graph(
|
||||
graph_id: str, user_id: Annotated[str, Depends(get_user_id)]
|
||||
) -> DeleteGraphResponse:
|
||||
if active_version := await graph_db.get_graph(graph_id, user_id=user_id):
|
||||
|
||||
def get_credentials(credentials_id: str) -> "Credentials | None":
|
||||
return integration_creds_manager.get(user_id, credentials_id)
|
||||
|
||||
await on_graph_deactivate(active_version, get_credentials)
|
||||
await on_graph_deactivate(active_version, user_id=user_id)
|
||||
|
||||
return {"version_counts": await graph_db.delete_graph(graph_id, user_id=user_id)}
|
||||
|
||||
@@ -521,24 +509,15 @@ async def update_graph(
|
||||
user_id, graph.id, graph.version
|
||||
)
|
||||
|
||||
def get_credentials(credentials_id: str) -> "Credentials | None":
|
||||
return integration_creds_manager.get(user_id, credentials_id)
|
||||
|
||||
# Handle activation of the new graph first to ensure continuity
|
||||
new_graph_version = await on_graph_activate(
|
||||
new_graph_version,
|
||||
get_credentials=get_credentials,
|
||||
)
|
||||
new_graph_version = await on_graph_activate(new_graph_version, user_id=user_id)
|
||||
# Ensure new version is the only active version
|
||||
await graph_db.set_graph_active_version(
|
||||
graph_id=graph_id, version=new_graph_version.version, user_id=user_id
|
||||
)
|
||||
if current_active_version:
|
||||
# Handle deactivation of the previously active version
|
||||
await on_graph_deactivate(
|
||||
current_active_version,
|
||||
get_credentials=get_credentials,
|
||||
)
|
||||
await on_graph_deactivate(current_active_version, user_id=user_id)
|
||||
|
||||
return new_graph_version
|
||||
|
||||
@@ -562,14 +541,8 @@ async def set_graph_active_version(
|
||||
|
||||
current_active_graph = await graph_db.get_graph(graph_id, user_id=user_id)
|
||||
|
||||
def get_credentials(credentials_id: str) -> "Credentials | None":
|
||||
return integration_creds_manager.get(user_id, credentials_id)
|
||||
|
||||
# Handle activation of the new graph first to ensure continuity
|
||||
await on_graph_activate(
|
||||
new_active_graph,
|
||||
get_credentials=get_credentials,
|
||||
)
|
||||
await on_graph_activate(new_active_graph, user_id=user_id)
|
||||
# Ensure new version is the only active version
|
||||
await graph_db.set_graph_active_version(
|
||||
graph_id=graph_id,
|
||||
@@ -584,10 +557,7 @@ async def set_graph_active_version(
|
||||
|
||||
if current_active_graph and current_active_graph.version != new_active_version:
|
||||
# Handle deactivation of the previously active version
|
||||
await on_graph_deactivate(
|
||||
current_active_graph,
|
||||
get_credentials=get_credentials,
|
||||
)
|
||||
await on_graph_deactivate(current_active_graph, user_id=user_id)
|
||||
|
||||
|
||||
@v1_router.post(
|
||||
@@ -660,11 +630,15 @@ async def _cancel_execution(graph_exec_id: str):
|
||||
exchange=execution_utils.GRAPH_EXECUTION_CANCEL_EXCHANGE,
|
||||
)
|
||||
|
||||
# Update the status of the graph & node executions
|
||||
await execution_db.update_graph_execution_stats(
|
||||
# Update the status of the graph execution
|
||||
graph_execution = await execution_db.update_graph_execution_stats(
|
||||
graph_exec_id,
|
||||
execution_db.ExecutionStatus.TERMINATED,
|
||||
)
|
||||
if graph_execution:
|
||||
await execution_event_bus().publish(graph_execution)
|
||||
|
||||
# Update the status of the node executions
|
||||
node_execs = [
|
||||
node_exec.model_copy(update={"status": execution_db.ExecutionStatus.TERMINATED})
|
||||
for node_exec in await execution_db.get_node_executions(
|
||||
@@ -676,7 +650,6 @@ async def _cancel_execution(graph_exec_id: str):
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
await execution_db.update_node_execution_status_batch(
|
||||
[node_exec.node_exec_id for node_exec in node_execs],
|
||||
execution_db.ExecutionStatus.TERMINATED,
|
||||
|
||||
@@ -736,10 +736,7 @@ async def fork_library_agent(library_agent_id: str, user_id: str):
|
||||
new_graph = await graph_db.fork_graph(
|
||||
original_agent.graph_id, original_agent.graph_version, user_id
|
||||
)
|
||||
new_graph = await on_graph_activate(
|
||||
new_graph,
|
||||
get_credentials=lambda id: integration_creds_manager.get(user_id, id),
|
||||
)
|
||||
new_graph = await on_graph_activate(new_graph, user_id=user_id)
|
||||
|
||||
# Create a library agent for the new graph
|
||||
return await create_library_agent(new_graph, user_id)
|
||||
|
||||
@@ -86,7 +86,7 @@ async def get_library_agent(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/marketplace/{store_listing_version_id}/",
|
||||
"/marketplace/{store_listing_version_id}",
|
||||
tags=["store, library"],
|
||||
response_model=library_model.LibraryAgent | None,
|
||||
)
|
||||
|
||||
@@ -146,11 +146,16 @@ async def get_store_agent_details(
|
||||
f"Agent {username}/{agent_name} not found"
|
||||
)
|
||||
|
||||
profile = await prisma.models.Profile.prisma().find_first(
|
||||
where={"username": username}
|
||||
)
|
||||
user_id = profile.userId if profile else None
|
||||
|
||||
# Retrieve StoreListing to get active_version_id and has_approved_version
|
||||
store_listing = await prisma.models.StoreListing.prisma().find_first(
|
||||
where=prisma.types.StoreListingWhereInput(
|
||||
slug=agent_name,
|
||||
owningUserId=username, # Direct equality check instead of 'has'
|
||||
owningUserId=user_id or "",
|
||||
),
|
||||
include={"ActiveVersion": True},
|
||||
)
|
||||
|
||||
@@ -2,8 +2,9 @@ import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
from typing import Callable
|
||||
from urllib.parse import quote, urljoin, urlparse, urlunparse
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import ParseResult as URL
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
import idna
|
||||
import requests as req
|
||||
@@ -44,17 +45,15 @@ def _is_ip_blocked(ip: str) -> bool:
|
||||
return any(ip_addr in network for network in BLOCKED_IP_NETWORKS)
|
||||
|
||||
|
||||
def _remove_insecure_headers(headers: dict, old_url: str, new_url: str) -> dict:
|
||||
def _remove_insecure_headers(headers: dict, old_url: URL, new_url: URL) -> dict:
|
||||
"""
|
||||
Removes sensitive headers (Authorization, Proxy-Authorization, Cookie)
|
||||
if the scheme/host/port of new_url differ from old_url.
|
||||
"""
|
||||
old_parsed = urlparse(old_url)
|
||||
new_parsed = urlparse(new_url)
|
||||
if (
|
||||
(old_parsed.scheme != new_parsed.scheme)
|
||||
or (old_parsed.hostname != new_parsed.hostname)
|
||||
or (old_parsed.port != new_parsed.port)
|
||||
(old_url.scheme != new_url.scheme)
|
||||
or (old_url.hostname != new_url.hostname)
|
||||
or (old_url.port != new_url.port)
|
||||
):
|
||||
headers.pop("Authorization", None)
|
||||
headers.pop("Proxy-Authorization", None)
|
||||
@@ -81,19 +80,16 @@ class HostSSLAdapter(HTTPAdapter):
|
||||
)
|
||||
|
||||
|
||||
def validate_url(
|
||||
url: str,
|
||||
trusted_origins: list[str],
|
||||
enable_dns_rebinding: bool = True,
|
||||
) -> tuple[str, str]:
|
||||
def validate_url(url: str, trusted_origins: list[str]) -> tuple[URL, bool, list[str]]:
|
||||
"""
|
||||
Validates the URL to prevent SSRF attacks by ensuring it does not point
|
||||
to a private, link-local, or otherwise blocked IP address — unless
|
||||
the hostname is explicitly trusted.
|
||||
|
||||
Returns a tuple of:
|
||||
- pinned_url: a URL that has the netloc replaced with the validated IP
|
||||
- ascii_hostname: the original ASCII hostname (IDNA-decoded) for use in the Host header
|
||||
Returns:
|
||||
str: The validated, canonicalized, parsed URL
|
||||
is_trusted: Boolean indicating if the hostname is in trusted_origins
|
||||
ip_addresses: List of IP addresses for the host; empty if the host is trusted
|
||||
"""
|
||||
# Canonicalize URL
|
||||
url = url.strip("/ ").replace("\\", "/")
|
||||
@@ -122,45 +118,56 @@ def validate_url(
|
||||
if not HOSTNAME_REGEX.match(ascii_hostname):
|
||||
raise ValueError("Hostname contains invalid characters.")
|
||||
|
||||
# If hostname is trusted, skip IP-based checks but still return pinned URL
|
||||
if ascii_hostname in trusted_origins:
|
||||
pinned_netloc = ascii_hostname
|
||||
if parsed.port:
|
||||
pinned_netloc += f":{parsed.port}"
|
||||
# Check if hostname is trusted
|
||||
is_trusted = ascii_hostname in trusted_origins
|
||||
|
||||
pinned_url = urlunparse(
|
||||
(
|
||||
parsed.scheme,
|
||||
pinned_netloc,
|
||||
quote(parsed.path, safe="/%:@"),
|
||||
parsed.params,
|
||||
parsed.query,
|
||||
parsed.fragment,
|
||||
)
|
||||
)
|
||||
return pinned_url, ascii_hostname
|
||||
# If not trusted, validate IP addresses
|
||||
ip_addresses: list[str] = []
|
||||
if not is_trusted:
|
||||
# Resolve all IP addresses for the hostname
|
||||
ip_addresses = _resolve_host(ascii_hostname)
|
||||
|
||||
# Resolve all IP addresses for the hostname
|
||||
try:
|
||||
ip_list = [str(res[4][0]) for res in socket.getaddrinfo(ascii_hostname, None)]
|
||||
ipv4 = [ip for ip in ip_list if ":" not in ip]
|
||||
ipv6 = [ip for ip in ip_list if ":" in ip]
|
||||
ip_addresses = ipv4 + ipv6 # Prefer IPv4 over IPv6
|
||||
except socket.gaierror:
|
||||
raise ValueError(f"Unable to resolve IP address for hostname {ascii_hostname}")
|
||||
# Block any IP address that belongs to a blocked range
|
||||
for ip_str in ip_addresses:
|
||||
if _is_ip_blocked(ip_str):
|
||||
raise ValueError(
|
||||
f"Access to blocked or private IP address {ip_str} "
|
||||
f"for hostname {ascii_hostname} is not allowed."
|
||||
)
|
||||
|
||||
return (
|
||||
URL(
|
||||
parsed.scheme,
|
||||
ascii_hostname,
|
||||
quote(parsed.path, safe="/%:@"),
|
||||
parsed.params,
|
||||
parsed.query,
|
||||
parsed.fragment,
|
||||
),
|
||||
is_trusted,
|
||||
ip_addresses,
|
||||
)
|
||||
|
||||
|
||||
def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL:
|
||||
"""
|
||||
Pins a URL to a specific IP address to prevent DNS rebinding attacks.
|
||||
|
||||
Args:
|
||||
url: The original URL
|
||||
ip_addresses: List of IP addresses corresponding to the URL's host
|
||||
|
||||
Returns:
|
||||
pinned_url: The URL with hostname replaced with IP address
|
||||
"""
|
||||
if not url.hostname:
|
||||
raise ValueError(f"URL has no hostname: {url}")
|
||||
|
||||
if not ip_addresses:
|
||||
raise ValueError(f"No IP addresses found for {ascii_hostname}")
|
||||
# Resolve all IP addresses for the hostname
|
||||
ip_addresses = _resolve_host(url.hostname)
|
||||
|
||||
# Block any IP address that belongs to a blocked range
|
||||
for ip_str in ip_addresses:
|
||||
if _is_ip_blocked(ip_str):
|
||||
raise ValueError(
|
||||
f"Access to blocked or private IP address {ip_str} "
|
||||
f"for hostname {ascii_hostname} is not allowed."
|
||||
)
|
||||
|
||||
# Pin to the first valid IP (for SSRF defense).
|
||||
# Pin to the first valid IP (for SSRF defense)
|
||||
pinned_ip = ip_addresses[0]
|
||||
|
||||
# If it's IPv6, bracket it
|
||||
@@ -169,24 +176,31 @@ def validate_url(
|
||||
else:
|
||||
pinned_netloc = pinned_ip
|
||||
|
||||
if parsed.port:
|
||||
pinned_netloc += f":{parsed.port}"
|
||||
if url.port:
|
||||
pinned_netloc += f":{url.port}"
|
||||
|
||||
if not enable_dns_rebinding:
|
||||
pinned_netloc = ascii_hostname
|
||||
|
||||
pinned_url = urlunparse(
|
||||
(
|
||||
parsed.scheme,
|
||||
pinned_netloc,
|
||||
quote(parsed.path, safe="/%:@"),
|
||||
parsed.params,
|
||||
parsed.query,
|
||||
parsed.fragment,
|
||||
)
|
||||
return URL(
|
||||
url.scheme,
|
||||
pinned_netloc,
|
||||
url.path,
|
||||
url.params,
|
||||
url.query,
|
||||
url.fragment,
|
||||
)
|
||||
|
||||
return pinned_url, ascii_hostname # (pinned_url, original_hostname)
|
||||
|
||||
def _resolve_host(hostname: str) -> list[str]:
|
||||
try:
|
||||
ip_list = [str(res[4][0]) for res in socket.getaddrinfo(hostname, None)]
|
||||
ipv4 = [ip for ip in ip_list if ":" not in ip]
|
||||
ipv6 = [ip for ip in ip_list if ":" in ip]
|
||||
ip_addresses = ipv4 + ipv6 # Prefer IPv4 over IPv6
|
||||
except socket.gaierror:
|
||||
raise ValueError(f"Unable to resolve IP address for hostname {hostname}")
|
||||
|
||||
if not ip_addresses:
|
||||
raise ValueError(f"No IP addresses found for {hostname}")
|
||||
return ip_addresses
|
||||
|
||||
|
||||
class Requests:
|
||||
@@ -200,7 +214,7 @@ class Requests:
|
||||
self,
|
||||
trusted_origins: list[str] | None = None,
|
||||
raise_for_status: bool = True,
|
||||
extra_url_validator: Callable[[str], str] | None = None,
|
||||
extra_url_validator: Callable[[URL], URL] | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
):
|
||||
self.trusted_origins = []
|
||||
@@ -224,12 +238,18 @@ class Requests:
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> req.Response:
|
||||
# Validate URL and get trust status
|
||||
url, is_trusted, ip_addresses = validate_url(url, self.trusted_origins)
|
||||
|
||||
# Apply any extra user-defined validation/transformation
|
||||
if self.extra_url_validator is not None:
|
||||
url = self.extra_url_validator(url)
|
||||
|
||||
# Validate URL and get pinned URL + hostname
|
||||
pinned_url, hostname = validate_url(url, self.trusted_origins)
|
||||
# Pin the URL if untrusted
|
||||
hostname = url.hostname
|
||||
original_url = url.geturl()
|
||||
if not is_trusted:
|
||||
url = pin_url(url, ip_addresses)
|
||||
|
||||
# Merge any extra headers
|
||||
headers = dict(headers) if headers else {}
|
||||
@@ -240,27 +260,30 @@ class Requests:
|
||||
|
||||
# If untrusted, the hostname in the URL is replaced with the corresponding
|
||||
# IP address, and we need to override the Host header with the actual hostname.
|
||||
if (pinned := urlparse(pinned_url)).hostname != hostname:
|
||||
if url.hostname != hostname:
|
||||
headers["Host"] = hostname
|
||||
|
||||
# If hostname was untrusted and we replaced it by (pinned it to) its IP,
|
||||
# we also need to attach a custom SNI adapter to make SSL work:
|
||||
mount_prefix = f"{pinned.scheme}://{pinned.hostname}"
|
||||
if pinned.port:
|
||||
mount_prefix += f":{pinned.port}"
|
||||
adapter = HostSSLAdapter(ssl_hostname=hostname)
|
||||
session.mount("https://", adapter)
|
||||
|
||||
# Perform the request with redirects disabled for manual handling
|
||||
response = session.request(
|
||||
method,
|
||||
pinned_url,
|
||||
url.geturl(),
|
||||
headers=headers,
|
||||
allow_redirects=False,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Replace response URLs with the original host for clearer error messages
|
||||
if url.hostname != hostname:
|
||||
response.url = original_url
|
||||
if response.request is not None:
|
||||
response.request.url = original_url
|
||||
|
||||
if self.raise_for_status:
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -275,13 +298,13 @@ class Requests:
|
||||
|
||||
# The base URL is the pinned_url we just used
|
||||
# so that relative redirects resolve correctly.
|
||||
new_url = urljoin(pinned_url, location)
|
||||
redirect_url = urlparse(urljoin(url.geturl(), location))
|
||||
# Carry forward the same headers but update Host
|
||||
new_headers = _remove_insecure_headers(dict(headers), url, new_url)
|
||||
new_headers = _remove_insecure_headers(headers, url, redirect_url)
|
||||
|
||||
return self.request(
|
||||
method,
|
||||
new_url,
|
||||
redirect_url.geturl(),
|
||||
headers=new_headers,
|
||||
allow_redirects=allow_redirects,
|
||||
max_redirects=max_redirects - 1,
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from functools import wraps
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -80,3 +81,24 @@ func_retry = retry(
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=30),
|
||||
)
|
||||
|
||||
|
||||
def continuous_retry(*, retry_delay: float = 1.0):
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
while True:
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"%s failed with %s — retrying in %.2f s",
|
||||
func.__name__,
|
||||
exc,
|
||||
retry_delay,
|
||||
)
|
||||
time.sleep(retry_delay)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "AgentNodeExecution_addedTime_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "AgentNodeExecution_agentGraphExecutionId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "AgentNodeExecution_agentNodeId_idx";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentNodeExecution_agentGraphExecutionId_agentNodeId_execut_idx" ON "AgentNodeExecution"("agentGraphExecutionId", "agentNodeId", "executionStatus");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentNodeExecution_addedTime_queuedTime_idx" ON "AgentNodeExecution"("addedTime", "queuedTime");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentNodeExecutionInputOutput_name_time_idx" ON "AgentNodeExecutionInputOutput"("name", "time");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationEvent_userNotificationBatchId_idx" ON "NotificationEvent"("userNotificationBatchId");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "OnboardingStep" ADD VALUE 'RUN_AGENTS';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserOnboarding" ADD COLUMN "agentRuns" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -68,6 +68,7 @@ enum OnboardingStep {
|
||||
AGENT_INPUT
|
||||
CONGRATS
|
||||
GET_RESULTS
|
||||
RUN_AGENTS
|
||||
// Marketplace
|
||||
MARKETPLACE_VISIT
|
||||
MARKETPLACE_ADD_AGENT
|
||||
@@ -93,6 +94,7 @@ model UserOnboarding {
|
||||
selectedStoreListingVersionId String?
|
||||
agentInput Json?
|
||||
onboardingAgentExecutionId String?
|
||||
agentRuns Int @default(0)
|
||||
|
||||
userId String @unique
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -195,6 +197,8 @@ model NotificationEvent {
|
||||
|
||||
type NotificationType
|
||||
data Json
|
||||
|
||||
@@index([userNotificationBatchId])
|
||||
}
|
||||
|
||||
model UserNotificationBatch {
|
||||
@@ -377,9 +381,8 @@ model AgentNodeExecution {
|
||||
|
||||
stats Json?
|
||||
|
||||
@@index([agentGraphExecutionId])
|
||||
@@index([agentNodeId])
|
||||
@@index([addedTime])
|
||||
@@index([agentGraphExecutionId, agentNodeId, executionStatus])
|
||||
@@index([addedTime, queuedTime])
|
||||
}
|
||||
|
||||
// This model describes the output of an AgentNodeExecution.
|
||||
@@ -402,6 +405,8 @@ model AgentNodeExecutionInputOutput {
|
||||
// Input and Output pin names are unique for each AgentNodeExecution.
|
||||
@@unique([referencedByInputExecId, referencedByOutputExecId, name])
|
||||
@@index([referencedByOutputExecId])
|
||||
// Composite index for `upsert_execution_input`.
|
||||
@@index([name, time])
|
||||
}
|
||||
|
||||
// Webhook that is registered with a provider and propagates to one or more nodes
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import pytest
|
||||
|
||||
from backend.util.request import validate_url
|
||||
from backend.util.request import pin_url, validate_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, trusted_origins, expected_value, should_raise",
|
||||
"raw_url, trusted_origins, expected_value, should_raise",
|
||||
[
|
||||
# Rejected IP ranges
|
||||
("localhost", [], None, True),
|
||||
@@ -55,14 +55,14 @@ from backend.util.request import validate_url
|
||||
],
|
||||
)
|
||||
def test_validate_url_no_dns_rebinding(
|
||||
url, trusted_origins, expected_value, should_raise
|
||||
raw_url: str, trusted_origins: list[str], expected_value: str, should_raise: bool
|
||||
):
|
||||
if should_raise:
|
||||
with pytest.raises(ValueError):
|
||||
validate_url(url, trusted_origins, enable_dns_rebinding=False)
|
||||
validate_url(raw_url, trusted_origins)
|
||||
else:
|
||||
url, host = validate_url(url, trusted_origins, enable_dns_rebinding=False)
|
||||
assert url == expected_value
|
||||
validated_url, _, _ = validate_url(raw_url, trusted_origins)
|
||||
assert validated_url.geturl() == expected_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -79,7 +79,11 @@ def test_validate_url_no_dns_rebinding(
|
||||
],
|
||||
)
|
||||
def test_dns_rebinding_fix(
|
||||
monkeypatch, hostname, resolved_ips, expect_error, expected_ip
|
||||
monkeypatch,
|
||||
hostname: str,
|
||||
resolved_ips: list[str],
|
||||
expect_error: bool,
|
||||
expected_ip: str,
|
||||
):
|
||||
"""
|
||||
Tests that validate_url pins the first valid public IP address, and rejects
|
||||
@@ -96,11 +100,13 @@ def test_dns_rebinding_fix(
|
||||
if expect_error:
|
||||
# If any IP is blocked, we expect a ValueError
|
||||
with pytest.raises(ValueError):
|
||||
validate_url(hostname, [])
|
||||
url, _, ip_addresses = validate_url(hostname, [])
|
||||
pin_url(url, ip_addresses)
|
||||
else:
|
||||
pinned_url, ascii_hostname = validate_url(hostname, [])
|
||||
url, _, ip_addresses = validate_url(hostname, [])
|
||||
pinned_url = pin_url(url, ip_addresses).geturl()
|
||||
# The pinned_url should contain the first valid IP
|
||||
assert pinned_url.startswith("http://") or pinned_url.startswith("https://")
|
||||
assert expected_ip in pinned_url
|
||||
# The ascii_hostname should match our original hostname after IDNA encoding
|
||||
assert ascii_hostname == hostname
|
||||
# The unpinned URL's hostname should match our original IDNA encoded hostname
|
||||
assert url.hostname == hostname
|
||||
|
||||
Binary file not shown.
@@ -95,6 +95,7 @@ export default function Page() {
|
||||
);
|
||||
updateState({
|
||||
onboardingAgentExecutionId: graph_exec_id,
|
||||
agentRuns: (state?.agentRuns || 0) + 1,
|
||||
});
|
||||
router.push("/onboarding/6-congrats");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { redirect } from "next/navigation";
|
||||
import { finishOnboarding } from "./6-congrats/actions";
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const api = new BackendAPI();
|
||||
@@ -11,7 +12,9 @@ export default async function OnboardingPage() {
|
||||
const onboarding = await api.getUserOnboarding();
|
||||
|
||||
// CONGRATS is the last step in intro onboarding
|
||||
if (onboarding.completedSteps.includes("CONGRATS")) redirect("/marketplace");
|
||||
if (onboarding.completedSteps.includes("GET_RESULTS"))
|
||||
redirect("/marketplace");
|
||||
else if (onboarding.completedSteps.includes("CONGRATS")) finishOnboarding();
|
||||
else if (onboarding.completedSteps.includes("AGENT_INPUT"))
|
||||
redirect("/onboarding/5-run");
|
||||
else if (onboarding.completedSteps.includes("AGENT_NEW_RUN"))
|
||||
|
||||
@@ -6,7 +6,7 @@ import FlowEditor from "@/components/Flow";
|
||||
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Home() {
|
||||
export default function BuilderPage() {
|
||||
const query = useSearchParams();
|
||||
const { completeStep } = useOnboarding();
|
||||
|
||||
|
||||
@@ -39,9 +39,11 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import LoadingBox, { LoadingSpinner } from "@/components/ui/loading";
|
||||
|
||||
export default function AgentRunsPage(): React.ReactElement {
|
||||
const { id: agentID }: { id: LibraryAgentID } = useParams();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const api = useBackendAPI();
|
||||
|
||||
@@ -66,10 +68,19 @@ export default function AgentRunsPage(): React.ReactElement {
|
||||
useState<boolean>(false);
|
||||
const [confirmingDeleteAgentRun, setConfirmingDeleteAgentRun] =
|
||||
useState<GraphExecutionMeta | null>(null);
|
||||
const { state: onboardingState, updateState: updateOnboardingState } =
|
||||
useOnboarding();
|
||||
const {
|
||||
state: onboardingState,
|
||||
updateState: updateOnboardingState,
|
||||
incrementRuns,
|
||||
} = useOnboarding();
|
||||
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Set page title with agent name
|
||||
useEffect(() => {
|
||||
if (agent) {
|
||||
document.title = `${agent.name} - Library - AutoGPT Platform`;
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
const openRunDraftView = useCallback(() => {
|
||||
selectView({ type: "run" });
|
||||
@@ -120,7 +131,11 @@ export default function AgentRunsPage(): React.ReactElement {
|
||||
}
|
||||
}, [selectedRun, onboardingState, updateOnboardingState]);
|
||||
|
||||
const lastRefresh = useRef<number>(0);
|
||||
const refreshPageData = useCallback(() => {
|
||||
if (Date.now() - lastRefresh.current < 2e3) return; // 2 second debounce
|
||||
lastRefresh.current = Date.now();
|
||||
|
||||
api.getLibraryAgent(agentID).then((agent) => {
|
||||
setAgent(agent);
|
||||
|
||||
@@ -156,6 +171,44 @@ export default function AgentRunsPage(): React.ReactElement {
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
refreshPageData();
|
||||
|
||||
// Show a toast when the WebSocket connection disconnects
|
||||
let connectionToast: ReturnType<typeof toast> | null = null;
|
||||
const cancelDisconnectHandler = api.onWebSocketDisconnect(() => {
|
||||
connectionToast ??= toast({
|
||||
title: "Connection to server was lost",
|
||||
variant: "destructive",
|
||||
description: (
|
||||
<div className="flex items-center">
|
||||
Trying to reconnect...
|
||||
<LoadingSpinner className="ml-1.5 size-3.5" />
|
||||
</div>
|
||||
),
|
||||
duration: Infinity, // show until connection is re-established
|
||||
dismissable: false,
|
||||
});
|
||||
});
|
||||
const cancelConnectHandler = api.onWebSocketConnect(() => {
|
||||
if (connectionToast)
|
||||
connectionToast.update({
|
||||
id: connectionToast.id,
|
||||
title: "✅ Connection re-established",
|
||||
variant: "default",
|
||||
description: (
|
||||
<div className="flex items-center">
|
||||
Refreshing data...
|
||||
<LoadingSpinner className="ml-1.5 size-3.5" />
|
||||
</div>
|
||||
),
|
||||
duration: 2000,
|
||||
dismissable: true,
|
||||
});
|
||||
connectionToast = null;
|
||||
});
|
||||
return () => {
|
||||
cancelDisconnectHandler();
|
||||
cancelConnectHandler();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to WebSocket updates for agent runs
|
||||
@@ -177,6 +230,10 @@ export default function AgentRunsPage(): React.ReactElement {
|
||||
(data) => {
|
||||
if (data.graph_id != agent?.graph_id) return;
|
||||
|
||||
if (data.status == "COMPLETED") {
|
||||
incrementRuns();
|
||||
}
|
||||
|
||||
setAgentRuns((prev) => {
|
||||
const index = prev.findIndex((run) => run.id === data.id);
|
||||
if (index === -1) {
|
||||
@@ -195,7 +252,7 @@ export default function AgentRunsPage(): React.ReactElement {
|
||||
return () => {
|
||||
detachExecUpdateHandler();
|
||||
};
|
||||
}, [api, agent?.graph_id, selectedView.id]);
|
||||
}, [api, agent?.graph_id, selectedView.id, incrementRuns]);
|
||||
|
||||
// Pre-load selectedRun based on selectedView
|
||||
useEffect(() => {
|
||||
@@ -313,9 +370,15 @@ export default function AgentRunsPage(): React.ReactElement {
|
||||
[agent, downloadGraph],
|
||||
);
|
||||
|
||||
const onRun = useCallback(
|
||||
(runID: GraphExecutionID) => {
|
||||
selectRun(runID);
|
||||
},
|
||||
[selectRun],
|
||||
);
|
||||
|
||||
if (!agent || !graph) {
|
||||
/* TODO: implement loading indicators / skeleton page */
|
||||
return <span>Loading...</span>;
|
||||
return <LoadingBox className="h-[90vh]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -354,14 +417,14 @@ export default function AgentRunsPage(): React.ReactElement {
|
||||
graph={graphVersions.current[selectedRun.graph_version] ?? graph}
|
||||
run={selectedRun}
|
||||
agentActions={agentActions}
|
||||
onRun={(runID) => selectRun(runID)}
|
||||
onRun={onRun}
|
||||
deleteRun={() => setConfirmingDeleteAgentRun(selectedRun)}
|
||||
/>
|
||||
)
|
||||
) : selectedView.type == "run" ? (
|
||||
<AgentRunDraftView
|
||||
graph={graph}
|
||||
onRun={(runID) => selectRun(runID)}
|
||||
onRun={onRun}
|
||||
agentActions={agentActions}
|
||||
/>
|
||||
) : selectedView.type == "schedule" ? (
|
||||
@@ -369,11 +432,11 @@ export default function AgentRunsPage(): React.ReactElement {
|
||||
<AgentScheduleDetailsView
|
||||
graph={graph}
|
||||
schedule={selectedSchedule}
|
||||
onForcedRun={(runID) => selectRun(runID)}
|
||||
onForcedRun={onRun}
|
||||
agentActions={agentActions}
|
||||
/>
|
||||
)
|
||||
) : null) || <p>Loading...</p>}
|
||||
) : null) || <LoadingBox className="h-[70vh]" />}
|
||||
|
||||
<DeleteConfirmDialog
|
||||
entityType="agent"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Metadata } from "next/types";
|
||||
|
||||
import {
|
||||
ArrowBottomRightIcon,
|
||||
@@ -11,11 +12,15 @@ import LibraryActionSubHeader from "@/components/library/library-action-sub-head
|
||||
import LibraryActionHeader from "@/components/library/library-action-header";
|
||||
import LibraryAgentList from "@/components/library/library-agent-list";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Library - AutoGPT Platform",
|
||||
description: "Your collection of Agents on the AutoGPT Platform",
|
||||
};
|
||||
|
||||
/**
|
||||
* LibraryPage Component
|
||||
* Main component that manages the library interface including agent listing and actions
|
||||
*/
|
||||
|
||||
export default function LibraryPage() {
|
||||
return (
|
||||
<main className="container min-h-screen space-y-4 pb-20 sm:px-8 md:px-12">
|
||||
|
||||
@@ -35,8 +35,8 @@ export async function logout() {
|
||||
async function shouldShowOnboarding() {
|
||||
const api = new BackendAPI();
|
||||
return (
|
||||
!(await api.isOnboardingEnabled()) ||
|
||||
(await api.getUserOnboarding()).completedSteps.includes("CONGRATS")
|
||||
(await api.isOnboardingEnabled()) &&
|
||||
!(await api.getUserOnboarding()).completedSteps.includes("CONGRATS")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import Spinner from "@/components/Spinner";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
import {
|
||||
AuthCard,
|
||||
AuthHeader,
|
||||
@@ -98,7 +98,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
if (isUserLoading || user) {
|
||||
return <Spinner className="h-[80vh]" />;
|
||||
return <LoadingBox className="h-[80vh]" />;
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
@@ -163,6 +163,7 @@ export default function LoginPage() {
|
||||
onVerify={turnstile.handleVerify}
|
||||
onExpire={turnstile.handleExpire}
|
||||
onError={turnstile.handleError}
|
||||
setWidgetId={turnstile.setWidgetId}
|
||||
action="login"
|
||||
shouldRender={turnstile.shouldRender}
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,7 @@ export default async function Page({
|
||||
});
|
||||
const libraryAgent = user
|
||||
? await api
|
||||
.getLibraryAgentByStoreListingVersionID(agent.store_listing_version_id)
|
||||
.getLibraryAgentByStoreListingVersionID(agent.active_version_id || "")
|
||||
.catch((error) => {
|
||||
console.error("Failed to fetch library agent:", error);
|
||||
return null;
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
CreatorDetails as Creator,
|
||||
StoreAgent,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
|
||||
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
|
||||
import { Metadata } from "next";
|
||||
@@ -65,11 +61,11 @@ export default async function Page({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4 sm:gap-6 md:gap-8">
|
||||
<p className="font-geist text-underline-position-from-font text-decoration-skip-none text-left text-base font-medium leading-6">
|
||||
<p className="text-underline-position-from-font text-decoration-skip-none text-left font-poppins text-base font-medium leading-6">
|
||||
About
|
||||
</p>
|
||||
<div
|
||||
className="font-poppins text-[48px] font-normal leading-[59px] text-neutral-900 dark:text-zinc-50"
|
||||
className="text-[48px] font-normal leading-[59px] text-neutral-900 dark:text-zinc-50"
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
>
|
||||
{creator.description}
|
||||
@@ -92,9 +88,7 @@ export default async function Page({
|
||||
} catch (error) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<div className="font-neue text-2xl text-neutral-900">
|
||||
Creator not found
|
||||
</div>
|
||||
<div className="text-2xl text-neutral-900">Creator not found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,9 +102,9 @@ async function getStoreData() {
|
||||
|
||||
// FIX: Correct metadata
|
||||
export const metadata: Metadata = {
|
||||
title: "Marketplace - NextGen AutoGPT",
|
||||
title: "Marketplace - AutoGPT Platform",
|
||||
description: "Find and use AI Agents created by our community",
|
||||
applicationName: "NextGen AutoGPT Store",
|
||||
applicationName: "AutoGPT Marketplace",
|
||||
authors: [{ name: "AutoGPT Team" }],
|
||||
keywords: [
|
||||
"AI agents",
|
||||
@@ -118,22 +118,22 @@ export const metadata: Metadata = {
|
||||
follow: true,
|
||||
},
|
||||
openGraph: {
|
||||
title: "Marketplace - NextGen AutoGPT",
|
||||
title: "Marketplace - AutoGPT Platform",
|
||||
description: "Find and use AI Agents created by our community",
|
||||
type: "website",
|
||||
siteName: "NextGen AutoGPT Store",
|
||||
siteName: "AutoGPT Marketplace",
|
||||
images: [
|
||||
{
|
||||
url: "/images/store-og.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "NextGen AutoGPT Store",
|
||||
alt: "AutoGPT Marketplace",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Marketplace - NextGen AutoGPT",
|
||||
title: "Marketplace - AutoGPT Platform",
|
||||
description: "Find and use AI Agents created by our community",
|
||||
images: ["/images/store-twitter.png"],
|
||||
},
|
||||
|
||||
@@ -120,7 +120,7 @@ function SearchResults({
|
||||
<div className="mx-auto min-h-screen max-w-[1440px] px-10 lg:min-w-[1440px]">
|
||||
<div className="mt-8 flex items-center">
|
||||
<div className="flex-1">
|
||||
<h2 className="font-geist text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
<h2 className="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
Results for:
|
||||
</h2>
|
||||
<h1 className="font-poppins text-2xl font-semibold leading-[32px] text-neutral-800 dark:text-neutral-100">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Metadata } from "next/types";
|
||||
import { APIKeysSection } from "@/components/agptui/composite/APIKeySection";
|
||||
|
||||
export const metadata: Metadata = { title: "API Keys - AutoGPT Platform" };
|
||||
|
||||
const ApiKeysPage = () => {
|
||||
return (
|
||||
<div className="w-full pr-4 pt-24 md:pt-0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import Spinner from "@/components/Spinner";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
|
||||
export default function PrivatePage() {
|
||||
const { supabase, user, isUserLoading } = useSupabase();
|
||||
@@ -123,7 +123,7 @@ export default function PrivatePage() {
|
||||
);
|
||||
|
||||
if (isUserLoading) {
|
||||
return <Spinner className="h-[80vh]" />;
|
||||
return <LoadingBox className="h-[80vh]" />;
|
||||
}
|
||||
|
||||
if (!user || !supabase) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Metadata } from "next/types";
|
||||
import { ProfileInfoForm } from "@/components/agptui/ProfileInfoForm";
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { CreatorDetails } from "@/lib/autogpt-server-api/types";
|
||||
@@ -17,6 +18,8 @@ async function getProfileData(api: BackendAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
export const metadata: Metadata = { title: "Profile - AutoGPT Platform" };
|
||||
|
||||
export default async function Page({}: {}) {
|
||||
const api = new BackendAPI();
|
||||
const { profile } = await getProfileData(api);
|
||||
|
||||
@@ -4,8 +4,9 @@ import SettingsForm from "@/components/profile/settings/SettingsForm";
|
||||
import getServerUser from "@/lib/supabase/getServerUser";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getUserPreferences } from "./actions";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
title: "Settings - AutoGPT Platform",
|
||||
description: "Manage your account settings and preferences.",
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useCallback, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { changePassword, sendResetEmail } from "./actions";
|
||||
import Spinner from "@/components/Spinner";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
import { getBehaveAs } from "@/lib/utils";
|
||||
import { useTurnstile } from "@/hooks/useTurnstile";
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function ResetPasswordPage() {
|
||||
);
|
||||
|
||||
if (isUserLoading) {
|
||||
return <Spinner className="h-[80vh]" />;
|
||||
return <LoadingBox className="h-[80vh]" />;
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
@@ -175,7 +175,7 @@ export default function ResetPasswordPage() {
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
|
||||
Password needs to be at least 6 characters long
|
||||
Password needs to be at least 12 characters long
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -188,6 +188,7 @@ export default function ResetPasswordPage() {
|
||||
onVerify={changePasswordTurnstile.handleVerify}
|
||||
onExpire={changePasswordTurnstile.handleExpire}
|
||||
onError={changePasswordTurnstile.handleError}
|
||||
setWidgetId={changePasswordTurnstile.setWidgetId}
|
||||
action="change_password"
|
||||
shouldRender={changePasswordTurnstile.shouldRender}
|
||||
/>
|
||||
@@ -230,6 +231,7 @@ export default function ResetPasswordPage() {
|
||||
onVerify={sendEmailTurnstile.handleVerify}
|
||||
onExpire={sendEmailTurnstile.handleExpire}
|
||||
onError={sendEmailTurnstile.handleError}
|
||||
setWidgetId={sendEmailTurnstile.setWidgetId}
|
||||
action="reset_password"
|
||||
shouldRender={sendEmailTurnstile.shouldRender}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import Spinner from "@/components/Spinner";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
import {
|
||||
AuthCard,
|
||||
AuthHeader,
|
||||
@@ -94,7 +94,7 @@ export default function SignupPage() {
|
||||
}
|
||||
|
||||
if (isUserLoading || user) {
|
||||
return <Spinner className="h-[80vh]" />;
|
||||
return <LoadingBox className="h-[80vh]" />;
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
@@ -151,7 +151,7 @@ export default function SignupPage() {
|
||||
<PasswordInput {...field} autoComplete="new-password" />
|
||||
</FormControl>
|
||||
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
|
||||
Password needs to be at least 6 characters long
|
||||
Password needs to be at least 12 characters long
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -164,6 +164,7 @@ export default function SignupPage() {
|
||||
onVerify={turnstile.handleVerify}
|
||||
onExpire={turnstile.handleExpire}
|
||||
onError={turnstile.handleError}
|
||||
setWidgetId={turnstile.setWidgetId}
|
||||
action="signup"
|
||||
shouldRender={turnstile.shouldRender}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 99.6%; /* #FEFEFE */
|
||||
--background: 0 0% 98%; /* neutral-50#FAFAFA */
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
@@ -62,11 +62,7 @@
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
.font-neue {
|
||||
font-family: "PP Neue Montreal TT", sans-serif;
|
||||
@apply bg-background font-sans text-foreground antialiased transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import React, { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Poppins } from "next/font/google";
|
||||
import { Poppins } from "next/font/google";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import "./globals.css";
|
||||
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Providers } from "@/app/providers";
|
||||
import TallyPopupSimple from "@/components/TallyPopup";
|
||||
import OttoChatWidget from "@/components/OttoChatWidget";
|
||||
import { GoogleAnalytics } from "@/components/analytics/google-analytics";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
|
||||
const poppins = Poppins({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
@@ -22,7 +18,7 @@ const poppins = Poppins({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "NextGen AutoGPT",
|
||||
title: "AutoGPT Platform",
|
||||
description: "Your one stop shop to creating AI Agents",
|
||||
};
|
||||
|
||||
@@ -34,19 +30,14 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${poppins.variable} ${GeistSans.variable} ${GeistMono.variable} ${inter.variable}`}
|
||||
className={`${poppins.variable} ${GeistSans.variable} ${GeistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<GoogleAnalytics
|
||||
gaId={process.env.GA_MEASUREMENT_ID || "G-FH2XK2W4GN"} // This is the measurement Id for the Google Analytics dev project
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
"bg-neutral-50 antialiased transition-colors",
|
||||
inter.className,
|
||||
)}
|
||||
>
|
||||
<body>
|
||||
<Providers
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
@@ -57,9 +48,6 @@ export default async function RootLayout({
|
||||
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch">
|
||||
{children}
|
||||
<TallyPopupSimple />
|
||||
<Suspense fallback={null}>
|
||||
<OttoChatWidget />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Toaster />
|
||||
</Providers>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
MouseEvent,
|
||||
createContext,
|
||||
Suspense,
|
||||
} from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
@@ -49,6 +50,7 @@ import RunnerUIWrapper, {
|
||||
RunnerUIWrapperRef,
|
||||
} from "@/components/RunnerUIWrapper";
|
||||
import PrimaryActionBar from "@/components/PrimaryActionButton";
|
||||
import OttoChatWidget from "@/components/OttoChatWidget";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useCopyPaste } from "../hooks/useCopyPaste";
|
||||
import { CronScheduler } from "./cronScheduler";
|
||||
@@ -153,6 +155,13 @@ const FlowEditor: React.FC<{
|
||||
// It stores the dimension of all nodes with position as well
|
||||
const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({});
|
||||
|
||||
// Set page title with or without graph name
|
||||
useEffect(() => {
|
||||
document.title = savedAgent
|
||||
? `${savedAgent.name} - Builder - AutoGPT Platform`
|
||||
: `Builder - AutoGPT Platform`;
|
||||
}, [savedAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (params.get("resetTutorial") === "true") {
|
||||
localStorage.removeItem(TUTORIAL_STORAGE_KEY);
|
||||
@@ -700,6 +709,7 @@ const FlowEditor: React.FC<{
|
||||
}
|
||||
></ControlPanel>
|
||||
<PrimaryActionBar
|
||||
className="absolute bottom-0 left-1/2 z-20 -translate-x-1/2"
|
||||
onClickAgentOutputs={() => runnerUIRef.current?.openRunnerOutput()}
|
||||
onClickRunAgent={() => {
|
||||
if (!savedAgent) {
|
||||
@@ -739,6 +749,12 @@ const FlowEditor: React.FC<{
|
||||
scheduleRunner={scheduleRunner}
|
||||
requestSaveAndRun={requestSaveAndRun}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<OttoChatWidget
|
||||
graphID={flowID}
|
||||
className="fixed bottom-4 right-4 z-20"
|
||||
/>
|
||||
</Suspense>
|
||||
</FlowContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import useAgentGraph from "../hooks/useAgentGraph";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { GraphID } from "@/lib/autogpt-server-api/types";
|
||||
|
||||
import type { GraphID } from "@/lib/autogpt-server-api/types";
|
||||
import { askOtto } from "@/app/(platform)/build/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Message {
|
||||
type: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
const OttoChatWidget = () => {
|
||||
export default function OttoChatWidget({
|
||||
graphID,
|
||||
className,
|
||||
}: {
|
||||
graphID?: GraphID;
|
||||
className?: string;
|
||||
}): React.ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [includeGraphData, setIncludeGraphData] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const flowID = searchParams.get("flowID");
|
||||
const { nodes, edges } = useAgentGraph(
|
||||
flowID ? (flowID as GraphID) : undefined,
|
||||
);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
// Add welcome message when component mounts
|
||||
@@ -34,7 +32,7 @@ const OttoChatWidget = () => {
|
||||
setMessages([
|
||||
{
|
||||
type: "assistant",
|
||||
content: "Hello im Otto! Ask me anything about AutoGPT!",
|
||||
content: "Hello, I am Otto! Ask me anything about AutoGPT!",
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -84,7 +82,7 @@ const OttoChatWidget = () => {
|
||||
userMessage,
|
||||
conversationHistory,
|
||||
includeGraphData,
|
||||
flowID || undefined,
|
||||
graphID,
|
||||
);
|
||||
|
||||
// Check if the response contains an error
|
||||
@@ -131,13 +129,13 @@ const OttoChatWidget = () => {
|
||||
};
|
||||
|
||||
// Don't render the chat widget if we're not on the build page or in local mode
|
||||
if (process.env.NEXT_PUBLIC_BEHAVE_AS !== "CLOUD" || pathname !== "/build") {
|
||||
if (process.env.NEXT_PUBLIC_BEHAVE_AS !== "CLOUD") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<div className={className}>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex h-14 w-14 items-center justify-center whitespace-nowrap rounded-2xl bg-[rgba(65,65,64,1)] text-neutral-50 shadow transition-colors hover:bg-neutral-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90 dark:focus-visible:ring-neutral-300"
|
||||
@@ -160,7 +158,13 @@ const OttoChatWidget = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl",
|
||||
className,
|
||||
"z-40",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h2 className="font-semibold">Otto Assistant</h2>
|
||||
@@ -269,7 +273,7 @@ const OttoChatWidget = () => {
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{nodes && edges && (
|
||||
{graphID && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -303,6 +307,4 @@ const OttoChatWidget = () => {
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OttoChatWidget;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Clock, LogOut, ChevronLeft } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FaSpinner } from "react-icons/fa";
|
||||
import { Clock, LogOut } from "lucide-react";
|
||||
import { IconPlay, IconSquare } from "@/components/ui/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { FaSpinner } from "react-icons/fa";
|
||||
|
||||
interface PrimaryActionBarProps {
|
||||
onClickAgentOutputs: () => void;
|
||||
@@ -18,6 +19,7 @@ interface PrimaryActionBarProps {
|
||||
isScheduling: boolean;
|
||||
requestStopRun: () => void;
|
||||
runAgentTooltip: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
|
||||
@@ -29,6 +31,7 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
|
||||
isScheduling,
|
||||
requestStopRun,
|
||||
runAgentTooltip,
|
||||
className,
|
||||
}) => {
|
||||
const runButtonLabel = !isRunning ? "Run" : "Stop";
|
||||
|
||||
@@ -37,8 +40,13 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
|
||||
const runButtonOnClick = !isRunning ? onClickRunAgent : requestStopRun;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-1/2 z-50 flex w-fit -translate-x-1/2 transform select-none items-center justify-center p-4">
|
||||
<div className={`flex gap-1 md:gap-4`}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-fit select-none items-center justify-center p-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1 md:gap-4">
|
||||
<Tooltip key="ViewOutputs" delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
export default function Spinner({ className }: { className?: string }) {
|
||||
const spinnerClasses = `mr-2 h-16 w-16 animate-spin ${className || ""}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<LoaderCircle className={spinnerClasses} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,12 +56,12 @@ const TallyPopupSimple = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-1 right-24 z-50 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
|
||||
<div className="fixed bottom-1 right-24 z-20 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
|
||||
{show_tutorial && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={resetTutorial}
|
||||
className="mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left font-inter text-lg font-medium leading-6"
|
||||
className="mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left font-sans text-lg font-medium leading-6"
|
||||
>
|
||||
Tutorial
|
||||
</Button>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addDollars } from "@/app/admin/spending/actions";
|
||||
import { addDollars } from "@/app/(platform)/admin/spending/actions";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
|
||||
export function AdminAddMoneyButton({
|
||||
@@ -99,7 +99,6 @@ export function AdminAddMoneyButton({
|
||||
id="dollarAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="rounded-l-none"
|
||||
value={dollarAmount}
|
||||
onChange={(e) => setDollarAmount(e.target.value)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
import { PaginationControls } from "../../ui/pagination-controls";
|
||||
import { SearchAndFilterAdminSpending } from "./search-filter-form";
|
||||
import { getUsersTransactionHistory } from "@/app/admin/spending/actions";
|
||||
import { getUsersTransactionHistory } from "@/app/(platform)/admin/spending/actions";
|
||||
import { AdminAddMoneyButton } from "./add-money-button";
|
||||
import { CreditTransactionType } from "@/lib/autogpt-server-api";
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { IconRefresh, IconSquare } from "@/components/ui/icons";
|
||||
import { useToastOnFail } from "@/components/ui/use-toast";
|
||||
import ActionButtonGroup from "@/components/agptui/action-button-group";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import {
|
||||
@@ -133,7 +134,8 @@ export default function AgentRunDetailsView({
|
||||
| null
|
||||
| undefined = useMemo(() => {
|
||||
if (!("outputs" in run)) return undefined;
|
||||
if (!["running", "success", "failed"].includes(runStatus)) return null;
|
||||
if (!["running", "success", "failed", "stopped"].includes(runStatus))
|
||||
return null;
|
||||
|
||||
// Add type info from agent input schema
|
||||
return Object.fromEntries(
|
||||
@@ -251,7 +253,7 @@ export default function AgentRunDetailsView({
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<p>Loading...</p>
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -270,7 +272,7 @@ export default function AgentRunDetailsView({
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>Loading...</p>
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AgentRunStatus } from "@/components/agents/agent-run-status-chip";
|
||||
import { useToastOnFail } from "@/components/ui/use-toast";
|
||||
import ActionButtonGroup from "@/components/agptui/action-button-group";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export default function AgentScheduleDetailsView({
|
||||
@@ -113,7 +114,7 @@ export default function AgentScheduleDetailsView({
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>Loading...</p>
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -99,7 +99,7 @@ export const AgentImageItem: React.FC<AgentImageItemProps> = React.memo(
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="pr-1 font-neue text-sm font-medium leading-6 tracking-tight text-[#272727] dark:text-neutral-200 sm:pr-2 sm:text-base sm:leading-7 md:text-lg md:leading-8 lg:text-xl lg:leading-9">
|
||||
<span className="pr-1 text-sm font-medium leading-6 tracking-tight text-[#272727] dark:text-neutral-200 sm:pr-2 sm:text-base sm:leading-7 md:text-lg md:leading-8 lg:text-xl lg:leading-9">
|
||||
Play demo
|
||||
</span>
|
||||
<PlayIcon className="h-5 w-5 text-black dark:text-neutral-200 sm:h-6 sm:w-6 md:h-7 md:w-7" />
|
||||
|
||||
@@ -133,19 +133,19 @@ export const AgentInfo: FC<AgentInfoProps> = ({
|
||||
|
||||
{/* Creator */}
|
||||
<div className="mb-3 flex w-full items-center gap-1.5 lg:mb-4">
|
||||
<div className="font-sans text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
|
||||
<div className="text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
|
||||
by
|
||||
</div>
|
||||
<Link
|
||||
href={`/marketplace/creator/${encodeURIComponent(creator)}`}
|
||||
className="font-sans text-base font-medium text-neutral-800 hover:underline dark:text-neutral-200 sm:text-lg lg:text-xl"
|
||||
className="text-base font-medium text-neutral-800 hover:underline dark:text-neutral-200 sm:text-lg lg:text-xl"
|
||||
>
|
||||
{creator}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Short Description */}
|
||||
<div className="mb-4 line-clamp-2 w-full font-sans text-base font-normal leading-normal text-neutral-600 dark:text-neutral-300 sm:text-lg lg:mb-6 lg:text-xl lg:leading-7">
|
||||
<div className="mb-4 line-clamp-2 w-full text-base font-normal leading-normal text-neutral-600 dark:text-neutral-300 sm:text-lg lg:mb-6 lg:text-xl lg:leading-7">
|
||||
{shortDescription}
|
||||
</div>
|
||||
|
||||
@@ -182,12 +182,12 @@ export const AgentInfo: FC<AgentInfoProps> = ({
|
||||
{/* Rating and Runs */}
|
||||
<div className="mb-4 flex w-full items-center justify-between lg:mb-[44px]">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<span className="whitespace-nowrap font-sans text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
|
||||
<span className="whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
|
||||
{rating.toFixed(1)}
|
||||
</span>
|
||||
<div className="flex gap-0.5">{StarRatingIcons(rating)}</div>
|
||||
</div>
|
||||
<div className="whitespace-nowrap font-sans text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
|
||||
<div className="whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
|
||||
{runs.toLocaleString()} runs
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,24 +197,24 @@ export const AgentInfo: FC<AgentInfoProps> = ({
|
||||
|
||||
{/* Description Section */}
|
||||
<div className="mb-4 w-full lg:mb-[36px]">
|
||||
<div className="mb-1.5 font-sans text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
|
||||
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
|
||||
Description
|
||||
</div>
|
||||
<div className="whitespace-pre-line font-sans text-base font-normal leading-6 text-neutral-600 dark:text-neutral-400">
|
||||
<div className="whitespace-pre-line text-base font-normal leading-6 text-neutral-600 dark:text-neutral-400">
|
||||
{longDescription}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 lg:mb-[36px]">
|
||||
<div className="decoration-skip-ink-none mb-1.5 font-sans text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
|
||||
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
|
||||
Categories
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 sm:gap-2">
|
||||
{categories.map((category, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="decoration-skip-ink-none whitespace-nowrap rounded-full border border-neutral-600 bg-white px-2 py-0.5 font-sans text-base font-normal leading-6 text-neutral-800 underline-offset-[from-font] dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:px-[16px] sm:py-[10px]"
|
||||
className="decoration-skip-ink-none whitespace-nowrap rounded-full border border-neutral-600 bg-white px-2 py-0.5 text-base font-normal leading-6 text-neutral-800 underline-offset-[from-font] dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:px-[16px] sm:py-[10px]"
|
||||
>
|
||||
{category}
|
||||
</div>
|
||||
@@ -224,10 +224,10 @@ export const AgentInfo: FC<AgentInfoProps> = ({
|
||||
|
||||
{/* Version History */}
|
||||
<div className="flex w-full flex-col gap-0.5 sm:gap-1">
|
||||
<div className="decoration-skip-ink-none mb-1.5 font-sans text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
|
||||
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
|
||||
Version history
|
||||
</div>
|
||||
<div className="decoration-skip-ink-none font-sans text-base font-normal leading-6 text-neutral-600 underline-offset-[from-font] dark:text-neutral-400">
|
||||
<div className="decoration-skip-ink-none text-base font-normal leading-6 text-neutral-600 underline-offset-[from-font] dark:text-neutral-400">
|
||||
Last updated {lastUpdated}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">
|
||||
|
||||
@@ -37,7 +37,7 @@ export const BecomeACreator: React.FC<BecomeACreatorProps> = ({
|
||||
vision
|
||||
</h2>
|
||||
|
||||
<p className="font-geist mx-auto mb-8 max-w-[90%] text-lg font-normal leading-relaxed text-neutral-700 dark:text-neutral-300 md:mb-10 md:text-xl md:leading-loose lg:mb-14 lg:text-2xl">
|
||||
<p className="mx-auto mb-8 max-w-[90%] text-lg font-normal leading-relaxed text-neutral-700 dark:text-neutral-300 md:mb-10 md:text-xl md:leading-loose lg:mb-14 lg:text-2xl">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const BreadCrumbs: React.FC<BreadCrumbsProps> = ({ items }) => {
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Link href={item.link}>
|
||||
<span className="rounded py-1 pr-2 font-neue text-xl font-medium leading-9 tracking-tight text-[#272727] transition-colors duration-200 hover:text-gray-400 dark:text-neutral-100 dark:hover:text-gray-500">
|
||||
<span className="rounded py-1 pr-2 text-xl font-medium leading-9 tracking-tight text-[#272727] transition-colors duration-200 hover:text-gray-400 dark:text-neutral-100 dark:hover:text-gray-500">
|
||||
{item.name}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center whitespace-nowrap overflow-hidden font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-neutral-300 font-neue leading-9 tracking-tight",
|
||||
"inline-flex items-center whitespace-nowrap overflow-hidden font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-neutral-300 font-sans leading-9 tracking-tight",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -54,10 +54,10 @@ export const CreatorCard: React.FC<CreatorCardProps> = ({
|
||||
<h3 className="font-poppins text-2xl font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
|
||||
{creatorName}
|
||||
</h3>
|
||||
<p className="font-geist text-sm font-normal leading-normal text-neutral-600 dark:text-neutral-400">
|
||||
<p className="text-sm font-normal leading-normal text-neutral-600 dark:text-neutral-400">
|
||||
{bio}
|
||||
</p>
|
||||
<div className="font-geist text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
|
||||
<div className="text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
|
||||
{agentsUploaded} agents
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
|
||||
<div className="w-full font-poppins text-[35px] font-medium leading-10 text-neutral-900 dark:text-neutral-100 sm:text-[35px] sm:leading-10">
|
||||
{username}
|
||||
</div>
|
||||
<div className="font-geist w-full text-lg font-normal leading-6 text-neutral-800 dark:text-neutral-200 sm:text-xl sm:leading-7">
|
||||
<div className="w-full text-lg font-normal leading-6 text-neutral-800 dark:text-neutral-200 sm:text-xl sm:leading-7">
|
||||
@{handle}
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
|
||||
<div className="flex w-full flex-col items-start justify-start gap-3">
|
||||
<div className="h-px w-full bg-neutral-700 dark:bg-neutral-300" />
|
||||
<div className="flex flex-col items-start justify-start gap-2.5">
|
||||
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
<div className="w-full text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
Top categories
|
||||
</div>
|
||||
<div
|
||||
@@ -68,7 +68,7 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
|
||||
className="flex items-center justify-center gap-2.5 rounded-[34px] border border-neutral-600 px-4 py-3 dark:border-neutral-400"
|
||||
role="listitem"
|
||||
>
|
||||
<div className="font-neue text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
<div className="text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
{category}
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,11 +81,11 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
|
||||
<div className="h-px w-full bg-neutral-700 dark:bg-neutral-300" />
|
||||
<div className="flex w-full flex-col items-start justify-between gap-4 sm:flex-row sm:gap-0">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-2.5 sm:w-[164px]">
|
||||
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
<div className="w-full text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
Average rating
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="font-geist text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
|
||||
<div className="text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
|
||||
{averageRating.toFixed(1)}
|
||||
</div>
|
||||
<div
|
||||
@@ -98,10 +98,10 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start justify-start gap-2.5 sm:w-[164px]">
|
||||
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
<div className="w-full text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
Number of runs
|
||||
</div>
|
||||
<div className="font-geist text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
|
||||
<div className="text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
|
||||
{new Intl.NumberFormat().format(totalRuns)} runs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ export const CreatorLinks: React.FC<CreatorLinksProps> = ({ links }) => {
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-w-[200px] flex-1 items-center justify-between rounded-[34px] border border-neutral-600 px-5 py-3 dark:border-neutral-400"
|
||||
>
|
||||
<div className="font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
<div className="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
{new URL(url).hostname.replace("www.", "")}
|
||||
</div>
|
||||
<div className="relative h-6 w-6">
|
||||
@@ -30,7 +30,7 @@ export const CreatorLinks: React.FC<CreatorLinksProps> = ({ links }) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start gap-4">
|
||||
<div className="font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
<div className="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
Other links
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap gap-3">
|
||||
|
||||
@@ -44,7 +44,7 @@ export const FilterChips: React.FC<FilterChipsProps> = ({
|
||||
className="mb-2 flex cursor-pointer items-center justify-center gap-2 rounded-full border border-black/50 px-3 py-1 dark:border-white/50 lg:mb-3 lg:gap-2.5 lg:px-6 lg:py-2"
|
||||
onClick={() => handleBadgeClick(badge)}
|
||||
>
|
||||
<div className="font-neue text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
|
||||
<div className="text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
|
||||
{badge}
|
||||
</div>
|
||||
</Badge>
|
||||
|
||||
@@ -87,7 +87,7 @@ const PopoutMenuItem: React.FC<{
|
||||
{getIcon(icon)}
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`font-inter text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf] ${isActive ? "font-semibold text-[#272727] dark:text-[#ffffff]" : "text-[#474747] dark:text-[#cfcfcf]"}`}
|
||||
className={`font-sans text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf] ${isActive ? "font-semibold text-[#272727] dark:text-[#ffffff]" : "text-[#474747] dark:text-[#cfcfcf]"}`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
@@ -164,7 +164,7 @@ export const MobileNavBar: React.FC<MobileNavBarProps> = ({
|
||||
<div className="absolute left-0 top-0 text-lg font-semibold leading-7 text-[#474747] dark:text-[#cfcfcf]">
|
||||
{userName || "Unknown User"}
|
||||
</div>
|
||||
<div className="absolute left-0 top-6 font-inter text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf]">
|
||||
<div className="absolute left-0 top-6 font-sans text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf]">
|
||||
{userEmail || "No Email Set"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export const PublishAgentAwaitingReview: React.FC<
|
||||
>
|
||||
Agent is awaiting review
|
||||
</div>
|
||||
<div className="max-w-[280px] text-center font-inter text-sm font-normal leading-relaxed text-slate-500 dark:text-slate-400 sm:max-w-none">
|
||||
<div className="max-w-[280px] text-center font-sans text-sm font-normal leading-relaxed text-slate-500 dark:text-slate-400 sm:max-w-none">
|
||||
In the meantime you can check your progress on your Creator
|
||||
Dashboard page
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@ export const PublishAgentSelect: React.FC<PublishAgentSelectProps> = ({
|
||||
<h3 className="font-poppins text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Publish Agent
|
||||
</h3>
|
||||
<p className="font-geist text-sm font-normal text-neutral-600 dark:text-neutral-400">
|
||||
<p className="text-sm font-normal text-neutral-600 dark:text-neutral-400">
|
||||
Select your project that you'd like to publish
|
||||
</p>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@ export const PublishAgentSelect: React.FC<PublishAgentSelectProps> = ({
|
||||
<p className="font-poppins text-base font-medium leading-normal text-neutral-800 dark:text-neutral-100 sm:text-base">
|
||||
{agent.name}
|
||||
</p>
|
||||
<small className="font-geist text-xs font-normal leading-[14px] text-neutral-500 dark:text-neutral-400 sm:text-sm">
|
||||
<small className="text-xs font-normal leading-[14px] text-neutral-500 dark:text-neutral-400 sm:text-sm">
|
||||
Edited {agent.lastEdited}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -159,9 +159,16 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
|
||||
|
||||
const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
onSubmit(title, subheader, slug, description, images, youtubeLink, [
|
||||
category,
|
||||
]);
|
||||
const categories = category ? [category] : [];
|
||||
onSubmit(
|
||||
title,
|
||||
subheader,
|
||||
slug,
|
||||
description,
|
||||
images,
|
||||
youtubeLink,
|
||||
categories,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,10 +34,10 @@ export const SortDropdown: React.FC<{
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 focus:outline-none">
|
||||
<span className="font-geist text-base text-neutral-800 dark:text-neutral-200">
|
||||
<span className="text-base text-neutral-800 dark:text-neutral-200">
|
||||
Sort by
|
||||
</span>
|
||||
<span className="font-geist text-base text-neutral-800 dark:text-neutral-200">
|
||||
<span className="text-base text-neutral-800 dark:text-neutral-200">
|
||||
{selected.label}
|
||||
</span>
|
||||
<ChevronDownIcon className="h-4 w-4 text-neutral-800 dark:text-neutral-200" />
|
||||
|
||||
@@ -87,7 +87,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
|
||||
|
||||
{/* Third Section: Description */}
|
||||
<div className="mt-2.5 flex w-full flex-col">
|
||||
<p className="line-clamp-3 font-sans text-base font-normal leading-normal text-neutral-600 dark:text-neutral-400">
|
||||
<p className="line-clamp-3 text-base font-normal leading-normal text-neutral-600 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -98,11 +98,11 @@ export const StoreCard: React.FC<StoreCardProps> = ({
|
||||
{/* Fourth Section: Stats Row - aligned to bottom */}
|
||||
<div className="mt-5 w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-sans text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
<div className="text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
{runs.toLocaleString()} runs
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-sans text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
<span className="text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
{rating.toFixed(1)}
|
||||
</span>
|
||||
<div
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function Wallet() {
|
||||
<span className="font-poppins font-medium text-zinc-900">
|
||||
Your wallet
|
||||
</span>
|
||||
<div className="flex items-center font-inter text-sm font-semibold text-violet-700">
|
||||
<div className="flex items-center text-sm font-semibold text-violet-700">
|
||||
<div className="rounded-lg bg-violet-100 px-3 py-2">
|
||||
Wallet{" "}
|
||||
<span className="font-semibold">{formatCredits(credits)}</span>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const AgentsSection: React.FC<AgentsSectionProps> = ({
|
||||
sectionTitle,
|
||||
agents: allAgents,
|
||||
hideAvatars = false,
|
||||
margin = "37px",
|
||||
margin = "24px",
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -48,11 +48,12 @@ export const AgentsSection: React.FC<AgentsSectionProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="w-full max-w-[1360px]">
|
||||
<div
|
||||
className={`mb-[${margin}] font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200`}
|
||||
<h2
|
||||
style={{ marginBottom: margin }}
|
||||
className="font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200"
|
||||
>
|
||||
{sectionTitle}
|
||||
</div>
|
||||
</h2>
|
||||
{!displayedAgents || displayedAgents.length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
No agents found
|
||||
|
||||
@@ -155,7 +155,7 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
|
||||
if (!subHeading) missingFields.push("Sub-heading");
|
||||
if (!description) missingFields.push("Description");
|
||||
if (!imageUrls.length) missingFields.push("Image");
|
||||
if (!categories.length) missingFields.push("Categories");
|
||||
if (!categories.filter(Boolean).length) missingFields.push("Categories");
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
toast({
|
||||
@@ -166,6 +166,7 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredCategories = categories.filter(Boolean);
|
||||
setPublishData({
|
||||
name,
|
||||
sub_heading: subHeading,
|
||||
@@ -175,7 +176,7 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
|
||||
agent_id: selectedAgentId || "",
|
||||
agent_version: selectedAgentVersion || 0,
|
||||
slug,
|
||||
categories,
|
||||
categories: filteredCategories,
|
||||
});
|
||||
|
||||
// Create store submission
|
||||
@@ -189,7 +190,7 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
|
||||
agent_id: selectedAgentId || "",
|
||||
agent_version: selectedAgentVersion || 0,
|
||||
slug: slug.replace(/\s+/g, "-"),
|
||||
categories: categories,
|
||||
categories: filteredCategories,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating store submission:", error);
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface TurnstileProps {
|
||||
className?: string;
|
||||
id?: string;
|
||||
shouldRender?: boolean;
|
||||
setWidgetId?: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function Turnstile({
|
||||
@@ -22,6 +23,7 @@ export function Turnstile({
|
||||
className,
|
||||
id = "cf-turnstile",
|
||||
shouldRender = true,
|
||||
setWidgetId,
|
||||
}: TurnstileProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
@@ -68,7 +70,11 @@ export function Turnstile({
|
||||
|
||||
// Reset any existing widget
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.reset(widgetIdRef.current);
|
||||
try {
|
||||
window.turnstile.reset(widgetIdRef.current);
|
||||
} catch (err) {
|
||||
console.warn("Failed to reset existing Turnstile widget:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Render a new widget
|
||||
@@ -86,15 +92,32 @@ export function Turnstile({
|
||||
},
|
||||
action,
|
||||
});
|
||||
|
||||
// Notify the hook about the widget ID
|
||||
setWidgetId?.(widgetIdRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
try {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
} catch (err) {
|
||||
console.warn("Failed to remove Turnstile widget:", err);
|
||||
}
|
||||
setWidgetId?.(null);
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loaded, siteKey, onVerify, onExpire, onError, action, shouldRender]);
|
||||
}, [
|
||||
loaded,
|
||||
siteKey,
|
||||
onVerify,
|
||||
onExpire,
|
||||
onError,
|
||||
action,
|
||||
shouldRender,
|
||||
setWidgetId,
|
||||
]);
|
||||
|
||||
// Method to reset the widget manually
|
||||
const reset = useCallback(() => {
|
||||
|
||||
@@ -10,14 +10,14 @@ interface LibraryActionHeaderProps {}
|
||||
const LibraryActionHeader: React.FC<LibraryActionHeaderProps> = ({}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-[32px] hidden items-start justify-between bg-neutral-50 md:flex">
|
||||
<div className="mb-[32px] hidden items-start justify-between md:flex">
|
||||
{/* <LibraryNotificationDropdown /> */}
|
||||
<LibrarySearchBar />
|
||||
<LibraryUploadAgentDialog />
|
||||
</div>
|
||||
|
||||
{/* Mobile and tablet */}
|
||||
<div className="flex flex-col gap-4 bg-neutral-50 p-4 pt-[52px] md:hidden">
|
||||
<div className="flex flex-col gap-4 p-4 pt-[52px] md:hidden">
|
||||
<div className="flex w-full justify-between">
|
||||
{/* <LibraryNotificationDropdown /> */}
|
||||
<LibraryUploadAgentDialog />
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function LibraryAgentCard({
|
||||
<div className="items-between mt-4 flex w-full justify-between gap-3">
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
className="font-geist text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
|
||||
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
|
||||
>
|
||||
See runs
|
||||
</Link>
|
||||
@@ -87,7 +87,7 @@ export default function LibraryAgentCard({
|
||||
{can_access_graph && (
|
||||
<Link
|
||||
href={`/build?flowID=${agent_id}`}
|
||||
className="font-geist text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
|
||||
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
|
||||
>
|
||||
Open in builder
|
||||
</Link>
|
||||
|
||||
@@ -219,7 +219,6 @@ export default function LibraryUploadAgentDialog(): React.ReactNode {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
outline: "none",
|
||||
fontFamily: "var(--font-geist-sans)",
|
||||
color: "#525252",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { LoadingSpinner } from "@/components/ui/loading";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import Spinner from "../Spinner";
|
||||
|
||||
const variants = {
|
||||
default: "bg-zinc-700 hover:bg-zinc-800",
|
||||
@@ -55,7 +55,7 @@ export default function OnboardingButton({
|
||||
if (href && !disabled) {
|
||||
return (
|
||||
<Link href={href} onClick={onClickInternal} className={buttonClasses}>
|
||||
{isLoading && <Spinner className="h-5 w-5" />}
|
||||
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
|
||||
{icon && !isLoading && <>{icon}</>}
|
||||
{children}
|
||||
</Link>
|
||||
@@ -68,7 +68,7 @@ export default function OnboardingButton({
|
||||
disabled={disabled}
|
||||
className={buttonClasses}
|
||||
>
|
||||
{isLoading && <Spinner className="h-5 w-5" />}
|
||||
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
|
||||
{icon && !isLoading && <>{icon}</>}
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function StarRating({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"font-geist flex items-center gap-0.5 text-sm font-medium text-zinc-800",
|
||||
"flex items-center gap-0.5 text-sm font-medium text-zinc-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -22,22 +22,20 @@ interface TaskGroup {
|
||||
export function TaskGroups() {
|
||||
const [groups, setGroups] = useState<TaskGroup[]>([
|
||||
{
|
||||
name: "Run your first agent",
|
||||
name: "Run your first agents",
|
||||
isOpen: true,
|
||||
tasks: [
|
||||
{
|
||||
id: "CONGRATS",
|
||||
name: "Finish onboarding",
|
||||
id: "GET_RESULTS",
|
||||
name: "Complete onboarding and see your first agent's results",
|
||||
amount: 3,
|
||||
details: "Go through our step by step tutorial",
|
||||
details: "",
|
||||
},
|
||||
{
|
||||
id: "GET_RESULTS",
|
||||
name: "Get results from first agent",
|
||||
id: "RUN_AGENTS",
|
||||
name: "Run 10 agents",
|
||||
amount: 3,
|
||||
details:
|
||||
"Sit back and relax - your agent is running and will finish soon! See the results in the Library once it's done",
|
||||
video: "/onboarding/get-results.mp4",
|
||||
details: "Run agents from Library or Builder 10 times",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -308,7 +306,7 @@ export function TaskGroups() {
|
||||
>
|
||||
{task.details}
|
||||
</div>
|
||||
{task.video && (
|
||||
{task.video ? (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-6 aspect-video overflow-hidden rounded-lg transition-all duration-300 ease-in-out",
|
||||
@@ -329,6 +327,8 @@ export function TaskGroups() {
|
||||
)}
|
||||
></video>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-1" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,17 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { set } from "lodash";
|
||||
|
||||
const OnboardingContext = createContext<
|
||||
| {
|
||||
@@ -21,6 +32,7 @@ const OnboardingContext = createContext<
|
||||
step: number;
|
||||
setStep: (step: number) => void;
|
||||
completeStep: (step: OnboardingStep) => void;
|
||||
incrementRuns: () => void;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
@@ -60,6 +72,7 @@ export default function OnboardingProvider({
|
||||
const [state, setState] = useState<UserOnboarding | null>(null);
|
||||
// Step is used to control the progress bar, it's frontend only
|
||||
const [step, setStep] = useState(1);
|
||||
const [npsDialogOpen, setNpsDialogOpen] = useState(false);
|
||||
const api = useBackendAPI();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -109,6 +122,7 @@ export default function OnboardingProvider({
|
||||
selectedStoreListingVersionId: null,
|
||||
agentInput: null,
|
||||
onboardingAgentExecutionId: null,
|
||||
agentRuns: 0,
|
||||
...newState,
|
||||
};
|
||||
}
|
||||
@@ -129,10 +143,50 @@ export default function OnboardingProvider({
|
||||
[state, updateState],
|
||||
);
|
||||
|
||||
const incrementRuns = useCallback(() => {
|
||||
if (!state || state.completedSteps.includes("RUN_AGENTS")) return;
|
||||
|
||||
const finished = state.agentRuns + 1 >= 10;
|
||||
setNpsDialogOpen(finished);
|
||||
updateState({
|
||||
agentRuns: state.agentRuns + 1,
|
||||
...(finished && {
|
||||
completedSteps: [...state.completedSteps, "RUN_AGENTS"],
|
||||
}),
|
||||
});
|
||||
}, [api, state]);
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider
|
||||
value={{ state, updateState, step, setStep, completeStep }}
|
||||
value={{ state, updateState, step, setStep, completeStep, incrementRuns }}
|
||||
>
|
||||
<Dialog onOpenChange={setNpsDialogOpen} open={npsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>We'd love your feedback</DialogTitle>
|
||||
<DialogDescription>
|
||||
You've run 10 agents — amazing! We're constantly
|
||||
improving the platform, and your thoughts help shape what we build
|
||||
next. This 1-minute form is just a few quick questions to share
|
||||
how things are going.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setNpsDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Link href="https://tally.so/r/w4El0b" target="_blank">
|
||||
<Button type="button" onClick={() => setNpsDialogOpen(false)}>
|
||||
Give Feedback
|
||||
</Button>
|
||||
</Link>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
);
|
||||
|
||||
@@ -33,9 +33,9 @@ const formSchema = z
|
||||
.optional()
|
||||
.refine((val) => {
|
||||
// If password is provided, it must be at least 8 characters
|
||||
if (val) return val.length >= 8;
|
||||
if (val) return val.length >= 12;
|
||||
return true;
|
||||
}, "String must contain at least 8 character(s)"),
|
||||
}, "String must contain at least 12 character(s)"),
|
||||
confirmPassword: z.string().optional(),
|
||||
notifyOnAgentRun: z.boolean(),
|
||||
notifyOnZeroBalance: z.boolean(),
|
||||
|
||||
26
autogpt_platform/frontend/src/components/ui/loading.tsx
Normal file
26
autogpt_platform/frontend/src/components/ui/loading.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
export default function LoadingBox({
|
||||
className,
|
||||
spinnerSize,
|
||||
}: {
|
||||
className?: string;
|
||||
spinnerSize?: string | number;
|
||||
}) {
|
||||
const spinnerSizeClass =
|
||||
typeof spinnerSize == "string"
|
||||
? `size-[${spinnerSize}]`
|
||||
: typeof spinnerSize == "number"
|
||||
? `size-${spinnerSize}`
|
||||
: undefined;
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", className)}>
|
||||
<LoadingSpinner className={spinnerSizeClass} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ className }: { className?: string }) {
|
||||
return <LoaderCircle className={cn("size-16 animate-spin", className)} />;
|
||||
}
|
||||
@@ -13,10 +13,18 @@ import { useToast } from "@/components/ui/use-toast";
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
// This neat little feature makes the toaster buggy due to the following issue:
|
||||
// https://github.com/radix-ui/primitives/issues/2233
|
||||
// TODO: Re-enable when the above issue is fixed:
|
||||
// const swipeThreshold = toasts.some((toast) => toast.dismissable === false)
|
||||
// ? Infinity
|
||||
// : undefined;
|
||||
const swipeThreshold = undefined;
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<ToastProvider swipeThreshold={swipeThreshold}>
|
||||
{toasts.map(
|
||||
({ id, title, description, action, dismissable, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
@@ -25,10 +33,10 @@ export function Toaster() {
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
{dismissable !== false && <ToastClose />}
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
),
|
||||
)}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ type ToasterToast = ToastProps & {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
dismissable?: boolean;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function useAgentGraph(
|
||||
useState(false);
|
||||
const [nodes, setNodes] = useState<CustomNode[]>([]);
|
||||
const [edges, setEdges] = useState<CustomEdge[]>([]);
|
||||
const { state, completeStep } = useOnboarding();
|
||||
const { state, completeStep, incrementRuns } = useOnboarding();
|
||||
|
||||
const api = useMemo(
|
||||
() => new BackendAPI(process.env.NEXT_PUBLIC_AGPT_SERVER_URL!),
|
||||
@@ -655,7 +655,7 @@ export default function useAgentGraph(
|
||||
setSaveRunRequest({ request: "run", state: "error" });
|
||||
});
|
||||
|
||||
processedUpdates.current = processedUpdates.current = [];
|
||||
processedUpdates.current = [];
|
||||
}
|
||||
}
|
||||
// Handle stop request
|
||||
@@ -757,13 +757,14 @@ export default function useAgentGraph(
|
||||
// an empty set means the graph has finished running.
|
||||
cancelExecListener();
|
||||
setSaveRunRequest({ request: "none", state: "none" });
|
||||
incrementRuns();
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
fetchExecutions();
|
||||
}, [flowID, flowExecutionID]);
|
||||
}, [flowID, flowExecutionID, incrementRuns]);
|
||||
|
||||
// Check if node ids are synced with saved agent
|
||||
useEffect(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ interface UseTurnstileResult {
|
||||
reset: () => void;
|
||||
siteKey: string;
|
||||
shouldRender: boolean;
|
||||
setWidgetId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const TURNSTILE_SITE_KEY =
|
||||
@@ -34,7 +35,7 @@ export function useTurnstile({
|
||||
autoVerify = true,
|
||||
onSuccess,
|
||||
onError,
|
||||
resetOnError = false,
|
||||
resetOnError = true,
|
||||
}: UseTurnstileOptions = {}): UseTurnstileResult {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
@@ -60,26 +61,30 @@ export function useTurnstile({
|
||||
}
|
||||
}, [token, autoVerify, shouldRender]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && window.turnstile) {
|
||||
const originalRender = window.turnstile.render;
|
||||
window.turnstile.render = (container, options) => {
|
||||
const id = originalRender(container, options);
|
||||
setWidgetId(id);
|
||||
return id;
|
||||
};
|
||||
}
|
||||
const setWidgetIdCallback = useCallback((id: string | null) => {
|
||||
setWidgetId(id);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (shouldRender && window.turnstile && widgetId) {
|
||||
window.turnstile.reset(widgetId);
|
||||
// Always reset the state when reset is called, regardless of shouldRender
|
||||
// This ensures users can retry CAPTCHA after failed attempts
|
||||
setToken(null);
|
||||
setVerified(false);
|
||||
setVerifying(false);
|
||||
setError(null);
|
||||
|
||||
// Always reset the state when reset is called
|
||||
setToken(null);
|
||||
setVerified(false);
|
||||
setVerifying(false);
|
||||
setError(null);
|
||||
// Only reset the actual Turnstile widget if it exists and shouldRender is true
|
||||
if (
|
||||
shouldRender &&
|
||||
typeof window !== "undefined" &&
|
||||
window.turnstile &&
|
||||
widgetId
|
||||
) {
|
||||
try {
|
||||
window.turnstile.reset(widgetId);
|
||||
} catch (err) {
|
||||
console.warn("Failed to reset Turnstile widget:", err);
|
||||
}
|
||||
}
|
||||
}, [shouldRender, widgetId]);
|
||||
|
||||
@@ -106,6 +111,7 @@ export function useTurnstile({
|
||||
setError(newError);
|
||||
if (onError) onError(newError);
|
||||
if (resetOnError) {
|
||||
setToken(null);
|
||||
setVerified(false);
|
||||
}
|
||||
}
|
||||
@@ -119,6 +125,7 @@ export function useTurnstile({
|
||||
: new Error("Unknown error during verification");
|
||||
setError(newError);
|
||||
if (resetOnError) {
|
||||
setToken(null);
|
||||
setVerified(false);
|
||||
}
|
||||
setVerifying(false);
|
||||
@@ -138,6 +145,7 @@ export function useTurnstile({
|
||||
if (shouldRender) {
|
||||
setToken(null);
|
||||
setVerified(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [shouldRender]);
|
||||
|
||||
@@ -146,6 +154,7 @@ export function useTurnstile({
|
||||
if (shouldRender) {
|
||||
setError(err);
|
||||
if (resetOnError) {
|
||||
setToken(null);
|
||||
setVerified(false);
|
||||
}
|
||||
if (onError) onError(err);
|
||||
@@ -165,5 +174,6 @@ export function useTurnstile({
|
||||
reset,
|
||||
siteKey: TURNSTILE_SITE_KEY,
|
||||
shouldRender,
|
||||
setWidgetId: setWidgetIdCallback,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ export default class BackendAPI {
|
||||
private webSocket: WebSocket | null = null;
|
||||
private wsConnecting: Promise<void> | null = null;
|
||||
private wsOnConnectHandlers: Set<() => void> = new Set();
|
||||
private wsOnDisconnectHandlers: Set<() => void> = new Set();
|
||||
private wsMessageHandlers: Record<string, Set<(data: any) => void>> = {};
|
||||
|
||||
readonly HEARTBEAT_INTERVAL = 100_000; // 100 seconds
|
||||
@@ -993,43 +994,69 @@ export default class BackendAPI {
|
||||
return () => this.wsOnConnectHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* All handlers are invoked when the WebSocket disconnects.
|
||||
*
|
||||
* @returns a detacher for the passed handler.
|
||||
*/
|
||||
onWebSocketDisconnect(handler: () => void): () => void {
|
||||
this.wsOnDisconnectHandlers.add(handler);
|
||||
|
||||
// Return detacher
|
||||
return () => this.wsOnDisconnectHandlers.delete(handler);
|
||||
}
|
||||
|
||||
async connectWebSocket(): Promise<void> {
|
||||
this.wsConnecting ??= new Promise(async (resolve, reject) => {
|
||||
return (this.wsConnecting ??= new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const token =
|
||||
(await this.supabaseClient?.auth.getSession())?.data.session
|
||||
?.access_token || "";
|
||||
const wsUrlWithToken = `${this.wsUrl}?token=${token}`;
|
||||
this.webSocket = new WebSocket(wsUrlWithToken);
|
||||
this.webSocket.state = "connecting";
|
||||
|
||||
this.webSocket.onopen = () => {
|
||||
this.webSocket!.state = "connected";
|
||||
console.info("[BackendAPI] WebSocket connected to", this.wsUrl);
|
||||
this._startWSHeartbeat(); // Start heartbeat when connection opens
|
||||
this.wsOnConnectHandlers.forEach((handler) => handler());
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.webSocket.onclose = (event) => {
|
||||
console.warn("WebSocket connection closed", event);
|
||||
if (this.webSocket?.state == "connecting") {
|
||||
console.error(
|
||||
`[BackendAPI] WebSocket failed to connect: ${event.reason}`,
|
||||
event,
|
||||
);
|
||||
} else if (this.webSocket?.state == "connected") {
|
||||
console.warn(
|
||||
`[BackendAPI] WebSocket connection closed: ${event.reason}`,
|
||||
event,
|
||||
);
|
||||
}
|
||||
this.webSocket!.state = "closed";
|
||||
|
||||
this._stopWSHeartbeat(); // Stop heartbeat when connection closes
|
||||
this.wsConnecting = null;
|
||||
this.wsOnDisconnectHandlers.forEach((handler) => handler());
|
||||
// Attempt to reconnect after a delay
|
||||
setTimeout(() => this.connectWebSocket(), 1000);
|
||||
setTimeout(() => this.connectWebSocket().then(resolve), 1000);
|
||||
};
|
||||
|
||||
this.webSocket.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
this._stopWSHeartbeat(); // Stop heartbeat on error
|
||||
this.wsConnecting = null;
|
||||
reject(error);
|
||||
if (this.webSocket?.state == "connected") {
|
||||
console.error("[BackendAPI] WebSocket error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
this.webSocket.onmessage = (event) => this._handleWSMessage(event);
|
||||
} catch (error) {
|
||||
console.error("Error connecting to WebSocket:", error);
|
||||
console.error("[BackendAPI] Error connecting to WebSocket:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
return this.wsConnecting;
|
||||
}));
|
||||
}
|
||||
|
||||
disconnectWebSocket() {
|
||||
@@ -1098,6 +1125,12 @@ export default class BackendAPI {
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WebSocket {
|
||||
state: "connecting" | "connected" | "closed";
|
||||
}
|
||||
}
|
||||
|
||||
/* *** UTILITY TYPES *** */
|
||||
|
||||
type GraphCreateRequestBody = {
|
||||
|
||||
@@ -914,6 +914,7 @@ export type OnboardingStep =
|
||||
| "AGENT_INPUT"
|
||||
| "CONGRATS"
|
||||
| "GET_RESULTS"
|
||||
| "RUN_AGENTS"
|
||||
| "MARKETPLACE_VISIT"
|
||||
| "MARKETPLACE_ADD_AGENT"
|
||||
| "MARKETPLACE_RUN_AGENT"
|
||||
@@ -932,6 +933,7 @@ export interface UserOnboarding {
|
||||
selectedStoreListingVersionId: string | null;
|
||||
agentInput: { [key: string]: string | number } | null;
|
||||
onboardingAgentExecutionId: GraphExecutionID | null;
|
||||
agentRuns: number;
|
||||
}
|
||||
|
||||
/* *** UTILITIES *** */
|
||||
|
||||
@@ -4,5 +4,5 @@ test("has title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/NextGen AutoGPT/);
|
||||
await expect(page).toHaveTitle(/AutoGPT Platform/);
|
||||
});
|
||||
|
||||
@@ -23,11 +23,11 @@ export const signupFormSchema = z
|
||||
.trim(),
|
||||
password: z
|
||||
.string()
|
||||
.min(6, "Password must contain at least 6 characters")
|
||||
.min(12, "Password must contain at least 12 characters")
|
||||
.max(64, "Password must contain at most 64 characters"),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(6, "Password must contain at least 6 characters")
|
||||
.min(12, "Password must contain at least 12 characters")
|
||||
.max(64, "Password must contain at most 64 characters"),
|
||||
agreeToTerms: z.boolean().refine((value) => value === true, {
|
||||
message: "You must agree to the Terms of Use and Privacy Policy",
|
||||
@@ -50,11 +50,11 @@ export const changePasswordFormSchema = z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(6, "Password must contain at least 6 characters")
|
||||
.min(12, "Password must contain at least 12 characters")
|
||||
.max(64, "Password must contain at most 64 characters"),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(6, "Password must contain at least 6 characters")
|
||||
.min(12, "Password must contain at least 12 characters")
|
||||
.max(64, "Password must contain at most 64 characters"),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
|
||||
@@ -18,9 +18,7 @@ const config = {
|
||||
sans: ["var(--font-geist-sans)"],
|
||||
mono: ["var(--font-geist-mono)"],
|
||||
// Include the custom font family
|
||||
neue: ['"PP Neue Montreal TT"', "sans-serif"],
|
||||
poppins: ["var(--font-poppins)"],
|
||||
inter: ["var(--font-inter)"],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
|
||||
Reference in New Issue
Block a user