Merge branch 'dev' into redesigning-block-menu

This commit is contained in:
Abhimanyu Yadav
2025-06-02 10:34:16 +05:30
committed by GitHub
100 changed files with 1285 additions and 547 deletions

View File

@@ -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 }}.`
});

View 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 }}"
}

View File

@@ -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
View 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

View File

@@ -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)

View File

@@ -85,4 +85,3 @@ class ExaContentsBlock(Block):
yield "results", data.get("results", [])
except Exception as e:
yield "error", str(e)
yield "results", []

View File

@@ -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", []

View File

@@ -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", []

View File

@@ -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]:

View File

@@ -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

View File

@@ -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):

View File

@@ -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)
]

View File

@@ -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

View File

@@ -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"},

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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

View File

@@ -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},

View File

@@ -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 ------- #

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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},
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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");

View File

@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "OnboardingStep" ADD VALUE 'RUN_AGENTS';
-- AlterTable
ALTER TABLE "UserOnboarding" ADD COLUMN "agentRuns" INTEGER NOT NULL DEFAULT 0;

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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"))

View File

@@ -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();

View File

@@ -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"

View File

@@ -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">

View File

@@ -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")
);
}

View File

@@ -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}
/>

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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"],
},

View File

@@ -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">

View File

@@ -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">

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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.",
};

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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";

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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&apos;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>

View File

@@ -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 (

View File

@@ -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" />

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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);

View File

@@ -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(() => {

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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",

View File

@@ -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>

View File

@@ -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,
)}
>

View File

@@ -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" />
)}
</>
)}

View File

@@ -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&apos;d love your feedback</DialogTitle>
<DialogDescription>
You&apos;ve run 10 agents amazing! We&apos;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>
);

View File

@@ -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(),

View 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)} />;
}

View File

@@ -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>
);

View File

@@ -13,6 +13,7 @@ type ToasterToast = ToastProps & {
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
dismissable?: boolean;
};
const actionTypes = {

View File

@@ -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(() => {

View File

@@ -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,
};
}

View File

@@ -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 = {

View File

@@ -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 *** */

View File

@@ -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/);
});

View File

@@ -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, {

View File

@@ -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))",