Merge branch 'dev' into toran/open-2273-add-image-to-video-block

This commit is contained in:
Aarushi
2025-01-08 12:19:38 +00:00
committed by GitHub
121 changed files with 4414 additions and 5074 deletions

View File

@@ -1,40 +1,61 @@
# Ignore everything by default, selectively add things to context
classic/run
*
# AutoGPT
# Platform - Libs
!autogpt_platform/autogpt_libs/autogpt_libs/
!autogpt_platform/autogpt_libs/pyproject.toml
!autogpt_platform/autogpt_libs/poetry.lock
!autogpt_platform/autogpt_libs/README.md
# Platform - Backend
!autogpt_platform/backend/backend/
!autogpt_platform/backend/migrations/
!autogpt_platform/backend/schema.prisma
!autogpt_platform/backend/pyproject.toml
!autogpt_platform/backend/poetry.lock
!autogpt_platform/backend/README.md
# Platform - Market
!autogpt_platform/market/market/
!autogpt_platform/market/scripts.py
!autogpt_platform/market/schema.prisma
!autogpt_platform/market/pyproject.toml
!autogpt_platform/market/poetry.lock
!autogpt_platform/market/README.md
# Platform - Frontend
!autogpt_platform/frontend/src/
!autogpt_platform/frontend/public/
!autogpt_platform/frontend/package.json
!autogpt_platform/frontend/yarn.lock
!autogpt_platform/frontend/tsconfig.json
!autogpt_platform/frontend/README.md
## config
!autogpt_platform/frontend/*.config.*
!autogpt_platform/frontend/.env.*
# Classic - AutoGPT
!classic/original_autogpt/autogpt/
!classic/original_autogpt/pyproject.toml
!classic/original_autogpt/poetry.lock
!classic/original_autogpt/README.md
!classic/original_autogpt/tests/
# Benchmark
# Classic - Benchmark
!classic/benchmark/agbenchmark/
!classic/benchmark/pyproject.toml
!classic/benchmark/poetry.lock
!classic/benchmark/README.md
# Forge
# Classic - Forge
!classic/forge/
!classic/forge/pyproject.toml
!classic/forge/poetry.lock
!classic/forge/README.md
# Frontend
# Classic - Frontend
!classic/frontend/build/web/
# Platform
!autogpt_platform/
# Explicitly re-ignore some folders
.*
**/__pycache__
autogpt_platform/frontend/.next/
autogpt_platform/frontend/node_modules
autogpt_platform/frontend/.env.example
autogpt_platform/frontend/.env.local
autogpt_platform/backend/.env
autogpt_platform/backend/.venv/
autogpt_platform/market/.env

View File

@@ -89,28 +89,6 @@ updates:
- "minor"
- "patch"
# market (Poetry project)
- package-ecosystem: "pip"
directory: "autogpt_platform/market"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
target-branch: "dev"
commit-message:
prefix: "chore(market/deps)"
prefix-development: "chore(market/deps-dev)"
groups:
production-dependencies:
dependency-type: "production"
update-types:
- "minor"
- "patch"
development-dependencies:
dependency-type: "development"
update-types:
- "minor"
- "patch"
# GitHub Actions
- package-ecosystem: "github-actions"

View File

@@ -35,12 +35,6 @@ jobs:
env:
DATABASE_URL: ${{ secrets.BACKEND_DATABASE_URL }}
- name: Run Market Migrations
working-directory: ./autogpt_platform/market
run: |
python -m prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.MARKET_DATABASE_URL }}
trigger:
needs: migrate

View File

@@ -37,13 +37,6 @@ jobs:
env:
DATABASE_URL: ${{ secrets.BACKEND_DATABASE_URL }}
- name: Run Market Migrations
working-directory: ./autogpt_platform/market
run: |
python -m prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.MARKET_DATABASE_URL }}
trigger:
needs: migrate
runs-on: ubuntu-latest

View File

@@ -81,7 +81,7 @@ jobs:
- name: Check poetry.lock
run: |
poetry lock --no-update
poetry lock
if ! git diff --quiet poetry.lock; then
echo "Error: poetry.lock not up to date."

View File

@@ -88,6 +88,11 @@ jobs:
run: |
yarn test --project=${{ matrix.browser }}
- name: Print Docker Compose logs in debug mode
if: runner.debug
run: |
docker compose -f ../docker-compose.yml logs
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:

View File

@@ -1,126 +0,0 @@
name: AutoGPT Platform - Backend CI
on:
push:
branches: [master, dev, ci-test*]
paths:
- ".github/workflows/platform-market-ci.yml"
- "autogpt_platform/market/**"
pull_request:
branches: [master, dev, release-*]
paths:
- ".github/workflows/platform-market-ci.yml"
- "autogpt_platform/market/**"
merge_group:
concurrency:
group: ${{ format('backend-ci-{0}', github.head_ref && format('{0}-{1}', github.event_name, github.event.pull_request.number) || github.sha) }}
cancel-in-progress: ${{ startsWith(github.event_name, 'pull_request') }}
defaults:
run:
shell: bash
working-directory: autogpt_platform/market
jobs:
test:
permissions:
contents: read
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Setup Supabase
uses: supabase/setup-cli@v1
with:
version: latest
- id: get_date
name: Get date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Set up Python dependency cache
uses: actions/cache@v4
with:
path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/market/poetry.lock') }}
- name: Install Poetry (Unix)
run: |
curl -sSL https://install.python-poetry.org | python3 -
if [ "${{ runner.os }}" = "macOS" ]; then
PATH="$HOME/.local/bin:$PATH"
echo "$HOME/.local/bin" >> $GITHUB_PATH
fi
- name: Install Python dependencies
run: poetry install
- name: Generate Prisma Client
run: poetry run prisma generate
- id: supabase
name: Start Supabase
working-directory: .
run: |
supabase init
supabase start --exclude postgres-meta,realtime,storage-api,imgproxy,inbucket,studio,edge-runtime,logflare,vector,supavisor
supabase status -o env | sed 's/="/=/; s/"$//' >> $GITHUB_OUTPUT
# outputs:
# DB_URL, API_URL, GRAPHQL_URL, ANON_KEY, SERVICE_ROLE_KEY, JWT_SECRET
- name: Run Database Migrations
run: poetry run prisma migrate dev --name updates
env:
DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }}
- id: lint
name: Run Linter
run: poetry run lint
# Tests comment out because they do not work with prisma mock, nor have they been updated since they were created
# - name: Run pytest with coverage
# run: |
# if [[ "${{ runner.debug }}" == "1" ]]; then
# poetry run pytest -s -vv -o log_cli=true -o log_cli_level=DEBUG test
# else
# poetry run pytest -s -vv test
# fi
# if: success() || (failure() && steps.lint.outcome == 'failure')
# env:
# LOG_LEVEL: ${{ runner.debug && 'DEBUG' || 'INFO' }}
# DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }}
# SUPABASE_URL: ${{ steps.supabase.outputs.API_URL }}
# SUPABASE_SERVICE_ROLE_KEY: ${{ steps.supabase.outputs.SERVICE_ROLE_KEY }}
# SUPABASE_JWT_SECRET: ${{ steps.supabase.outputs.JWT_SECRET }}
# REDIS_HOST: 'localhost'
# REDIS_PORT: '6379'
# REDIS_PASSWORD: 'testpassword'
env:
CI: true
PLAIN_OUTPUT: True
RUN_ENV: local
PORT: 8080
# - name: Upload coverage reports to Codecov
# uses: codecov/codecov-action@v4
# with:
# token: ${{ secrets.CODECOV_TOKEN }}
# flags: backend,${{ runner.os }}

View File

@@ -110,7 +110,7 @@ repos:
- id: isort
name: Lint (isort) - AutoGPT Platform - Backend
alias: isort-platform-backend
entry: poetry -C autogpt_platform/backend run isort -p backend
entry: poetry -P autogpt_platform/backend run isort -p backend
files: ^autogpt_platform/backend/
types: [file, python]
language: system
@@ -118,7 +118,7 @@ repos:
- id: isort
name: Lint (isort) - Classic - AutoGPT
alias: isort-classic-autogpt
entry: poetry -C classic/original_autogpt run isort -p autogpt
entry: poetry -P classic/original_autogpt run isort -p autogpt
files: ^classic/original_autogpt/
types: [file, python]
language: system
@@ -126,7 +126,7 @@ repos:
- id: isort
name: Lint (isort) - Classic - Forge
alias: isort-classic-forge
entry: poetry -C classic/forge run isort -p forge
entry: poetry -P classic/forge run isort -p forge
files: ^classic/forge/
types: [file, python]
language: system
@@ -134,7 +134,7 @@ repos:
- id: isort
name: Lint (isort) - Classic - Benchmark
alias: isort-classic-benchmark
entry: poetry -C classic/benchmark run isort -p agbenchmark
entry: poetry -P classic/benchmark run isort -p agbenchmark
files: ^classic/benchmark/
types: [file, python]
language: system
@@ -178,7 +178,6 @@ repos:
name: Typecheck - AutoGPT Platform - Backend
alias: pyright-platform-backend
entry: poetry -C autogpt_platform/backend run pyright
args: [-p, autogpt_platform/backend, autogpt_platform/backend]
# include forge source (since it's a path dependency) but exclude *_test.py files:
files: ^autogpt_platform/(backend/((backend|test)/|(\w+\.py|poetry\.lock)$)|autogpt_libs/(autogpt_libs/.*(?<!_test)\.py|poetry\.lock)$)
types: [file]
@@ -189,7 +188,6 @@ repos:
name: Typecheck - AutoGPT Platform - Libs
alias: pyright-platform-libs
entry: poetry -C autogpt_platform/autogpt_libs run pyright
args: [-p, autogpt_platform/autogpt_libs, autogpt_platform/autogpt_libs]
files: ^autogpt_platform/autogpt_libs/(autogpt_libs/|poetry\.lock$)
types: [file]
language: system
@@ -199,7 +197,6 @@ repos:
name: Typecheck - Classic - AutoGPT
alias: pyright-classic-autogpt
entry: poetry -C classic/original_autogpt run pyright
args: [-p, classic/original_autogpt, classic/original_autogpt]
# include forge source (since it's a path dependency) but exclude *_test.py files:
files: ^(classic/original_autogpt/((autogpt|scripts|tests)/|poetry\.lock$)|classic/forge/(forge/.*(?<!_test)\.py|poetry\.lock)$)
types: [file]
@@ -210,7 +207,6 @@ repos:
name: Typecheck - Classic - Forge
alias: pyright-classic-forge
entry: poetry -C classic/forge run pyright
args: [-p, classic/forge, classic/forge]
files: ^classic/forge/(forge/|poetry\.lock$)
types: [file]
language: system
@@ -220,7 +216,6 @@ repos:
name: Typecheck - Classic - Benchmark
alias: pyright-classic-benchmark
entry: poetry -C classic/benchmark run pyright
args: [-p, classic/benchmark, classic/benchmark]
files: ^classic/benchmark/(agbenchmark/|tests/|poetry\.lock$)
types: [file]
language: system

View File

@@ -31,7 +31,8 @@ class RedisKeyedMutex:
try:
yield
finally:
lock.release()
if lock.locked():
lock.release()
def acquire(self, key: Any) -> "RedisLock":
"""Acquires and returns a lock with the given key"""
@@ -45,7 +46,7 @@ class RedisKeyedMutex:
return lock
def release(self, key: Any):
if lock := self.locks.get(key):
if (lock := self.locks.get(key)) and lock.locked() and lock.owned():
lock.release()
def release_all_locks(self):

View File

@@ -1415,29 +1415,29 @@ pyasn1 = ">=0.1.3"
[[package]]
name = "ruff"
version = "0.8.3"
version = "0.8.6"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6"},
{file = "ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939"},
{file = "ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea"},
{file = "ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964"},
{file = "ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9"},
{file = "ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936"},
{file = "ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3"},
{file = "ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3"},
{file = "ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1"},
{file = "ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117"},
{file = "ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe"},
{file = "ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d"},
{file = "ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a"},
{file = "ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76"},
{file = "ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764"},
{file = "ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905"},
{file = "ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162"},
{file = "ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5"},
]
[[package]]
@@ -1852,4 +1852,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4.0"
content-hash = "13a36d3be675cab4a3eb2e6a62a1b08df779bded4c7b9164d8be300dc08748d0"
content-hash = "bf1b0125759dadb1369fff05ffba64fea3e82b9b7a43d0068e1c80974a4ebc1c"

View File

@@ -21,7 +21,7 @@ supabase = "^2.10.0"
[tool.poetry.group.dev.dependencies]
redis = "^5.2.1"
ruff = "^0.8.3"
ruff = "^0.8.6"
[build-system]
requires = ["poetry-core"]

View File

@@ -17,12 +17,11 @@ RUN apt-get install -y libz-dev
RUN apt-get install -y libssl-dev
RUN apt-get install -y postgresql-client
ENV POETRY_VERSION=1.8.3
ENV POETRY_HOME=/opt/poetry
ENV POETRY_NO_INTERACTION=1
ENV POETRY_VIRTUALENVS_CREATE=false
ENV PATH=/opt/poetry/bin:$PATH
# Upgrade pip and setuptools to fix security vulnerabilities
RUN pip3 install --upgrade pip setuptools
@@ -32,25 +31,21 @@ RUN pip3 install poetry
COPY autogpt_platform/autogpt_libs /app/autogpt_platform/autogpt_libs
COPY autogpt_platform/backend/poetry.lock autogpt_platform/backend/pyproject.toml /app/autogpt_platform/backend/
WORKDIR /app/autogpt_platform/backend
RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi
RUN poetry install --no-ansi --no-root
# Generate Prisma client
COPY autogpt_platform/backend/schema.prisma ./
RUN poetry config virtualenvs.create false \
&& poetry run prisma generate
RUN poetry run prisma generate
FROM python:3.11.10-slim-bookworm AS server_dependencies
WORKDIR /app
ENV POETRY_VERSION=1.8.3
ENV POETRY_HOME=/opt/poetry
ENV POETRY_NO_INTERACTION=1
ENV POETRY_VIRTUALENVS_CREATE=false
ENV POETRY_HOME=/opt/poetry \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false
ENV PATH=/opt/poetry/bin:$PATH
# Upgrade pip and setuptools to fix security vulnerabilities
RUN pip3 install --upgrade pip setuptools
@@ -76,6 +71,7 @@ WORKDIR /app/autogpt_platform/backend
FROM server_dependencies AS server
COPY autogpt_platform/backend /app/autogpt_platform/backend
RUN poetry install --no-ansi --only-root
ENV DATABASE_URL=""
ENV PORT=8000

View File

@@ -76,7 +76,11 @@ class AgentExecutorBlock(Block):
)
if not event.node_id:
if event.status in [ExecutionStatus.COMPLETED, ExecutionStatus.FAILED]:
if event.status in [
ExecutionStatus.COMPLETED,
ExecutionStatus.TERMINATED,
ExecutionStatus.FAILED,
]:
logger.info(f"Execution {log_id} ended with status {event.status}")
break
else:

View File

@@ -699,3 +699,420 @@ class GithubDeleteBranchBlock(Block):
input_data.branch,
)
yield "status", status
class GithubCreateFileBlock(Block):
class Input(BlockSchema):
credentials: GithubCredentialsInput = GithubCredentialsField("repo")
repo_url: str = SchemaField(
description="URL of the GitHub repository",
placeholder="https://github.com/owner/repo",
)
file_path: str = SchemaField(
description="Path where the file should be created",
placeholder="path/to/file.txt",
)
content: str = SchemaField(
description="Content to write to the file",
placeholder="File content here",
)
branch: str = SchemaField(
description="Branch where the file should be created",
default="main",
)
commit_message: str = SchemaField(
description="Message for the commit",
default="Create new file",
)
class Output(BlockSchema):
url: str = SchemaField(description="URL of the created file")
sha: str = SchemaField(description="SHA of the commit")
error: str = SchemaField(
description="Error message if the file creation failed"
)
def __init__(self):
super().__init__(
id="8fd132ac-b917-428a-8159-d62893e8a3fe",
description="This block creates a new file in a GitHub repository.",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=GithubCreateFileBlock.Input,
output_schema=GithubCreateFileBlock.Output,
test_input={
"repo_url": "https://github.com/owner/repo",
"file_path": "test/file.txt",
"content": "Test content",
"branch": "main",
"commit_message": "Create test file",
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("url", "https://github.com/owner/repo/blob/main/test/file.txt"),
("sha", "abc123"),
],
test_mock={
"create_file": lambda *args, **kwargs: (
"https://github.com/owner/repo/blob/main/test/file.txt",
"abc123",
)
},
)
@staticmethod
def create_file(
credentials: GithubCredentials,
repo_url: str,
file_path: str,
content: str,
branch: str,
commit_message: str,
) -> tuple[str, str]:
api = get_api(credentials)
# Convert content to base64
content_bytes = content.encode("utf-8")
content_base64 = base64.b64encode(content_bytes).decode("utf-8")
# Create the file using the GitHub API
contents_url = f"{repo_url}/contents/{file_path}"
data = {
"message": commit_message,
"content": content_base64,
"branch": branch,
}
response = api.put(contents_url, json=data)
result = response.json()
return result["content"]["html_url"], result["commit"]["sha"]
def run(
self,
input_data: Input,
*,
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
try:
url, sha = self.create_file(
credentials,
input_data.repo_url,
input_data.file_path,
input_data.content,
input_data.branch,
input_data.commit_message,
)
yield "url", url
yield "sha", sha
except Exception as e:
yield "error", str(e)
class GithubUpdateFileBlock(Block):
class Input(BlockSchema):
credentials: GithubCredentialsInput = GithubCredentialsField("repo")
repo_url: str = SchemaField(
description="URL of the GitHub repository",
placeholder="https://github.com/owner/repo",
)
file_path: str = SchemaField(
description="Path to the file to update",
placeholder="path/to/file.txt",
)
content: str = SchemaField(
description="New content for the file",
placeholder="Updated content here",
)
branch: str = SchemaField(
description="Branch containing the file",
default="main",
)
commit_message: str = SchemaField(
description="Message for the commit",
default="Update file",
)
class Output(BlockSchema):
url: str = SchemaField(description="URL of the updated file")
sha: str = SchemaField(description="SHA of the commit")
error: str = SchemaField(description="Error message if the file update failed")
def __init__(self):
super().__init__(
id="30be12a4-57cb-4aa4-baf5-fcc68d136076",
description="This block updates an existing file in a GitHub repository.",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=GithubUpdateFileBlock.Input,
output_schema=GithubUpdateFileBlock.Output,
test_input={
"repo_url": "https://github.com/owner/repo",
"file_path": "test/file.txt",
"content": "Updated content",
"branch": "main",
"commit_message": "Update test file",
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("url", "https://github.com/owner/repo/blob/main/test/file.txt"),
("sha", "def456"),
],
test_mock={
"update_file": lambda *args, **kwargs: (
"https://github.com/owner/repo/blob/main/test/file.txt",
"def456",
)
},
)
@staticmethod
def update_file(
credentials: GithubCredentials,
repo_url: str,
file_path: str,
content: str,
branch: str,
commit_message: str,
) -> tuple[str, str]:
api = get_api(credentials)
# First get the current file to get its SHA
contents_url = f"{repo_url}/contents/{file_path}"
params = {"ref": branch}
response = api.get(contents_url, params=params)
current_file = response.json()
# Convert new content to base64
content_bytes = content.encode("utf-8")
content_base64 = base64.b64encode(content_bytes).decode("utf-8")
# Update the file
data = {
"message": commit_message,
"content": content_base64,
"sha": current_file["sha"],
"branch": branch,
}
response = api.put(contents_url, json=data)
result = response.json()
return result["content"]["html_url"], result["commit"]["sha"]
def run(
self,
input_data: Input,
*,
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
try:
url, sha = self.update_file(
credentials,
input_data.repo_url,
input_data.file_path,
input_data.content,
input_data.branch,
input_data.commit_message,
)
yield "url", url
yield "sha", sha
except Exception as e:
yield "error", str(e)
class GithubCreateRepositoryBlock(Block):
class Input(BlockSchema):
credentials: GithubCredentialsInput = GithubCredentialsField("repo")
name: str = SchemaField(
description="Name of the repository to create",
placeholder="my-new-repo",
)
description: str = SchemaField(
description="Description of the repository",
placeholder="A description of the repository",
default="",
)
private: bool = SchemaField(
description="Whether the repository should be private",
default=False,
)
auto_init: bool = SchemaField(
description="Whether to initialize the repository with a README",
default=True,
)
gitignore_template: str = SchemaField(
description="Git ignore template to use (e.g., Python, Node, Java)",
default="",
)
class Output(BlockSchema):
url: str = SchemaField(description="URL of the created repository")
clone_url: str = SchemaField(description="Git clone URL of the repository")
error: str = SchemaField(
description="Error message if the repository creation failed"
)
def __init__(self):
super().__init__(
id="029ec3b8-1cfd-46d3-b6aa-28e4a706efd1",
description="This block creates a new GitHub repository.",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=GithubCreateRepositoryBlock.Input,
output_schema=GithubCreateRepositoryBlock.Output,
test_input={
"name": "test-repo",
"description": "A test repository",
"private": False,
"auto_init": True,
"gitignore_template": "Python",
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("url", "https://github.com/owner/test-repo"),
("clone_url", "https://github.com/owner/test-repo.git"),
],
test_mock={
"create_repository": lambda *args, **kwargs: (
"https://github.com/owner/test-repo",
"https://github.com/owner/test-repo.git",
)
},
)
@staticmethod
def create_repository(
credentials: GithubCredentials,
name: str,
description: str,
private: bool,
auto_init: bool,
gitignore_template: str,
) -> tuple[str, str]:
api = get_api(credentials, convert_urls=False) # Disable URL conversion
data = {
"name": name,
"description": description,
"private": private,
"auto_init": auto_init,
}
if gitignore_template:
data["gitignore_template"] = gitignore_template
# Create repository using the user endpoint
response = api.post("https://api.github.com/user/repos", json=data)
result = response.json()
return result["html_url"], result["clone_url"]
def run(
self,
input_data: Input,
*,
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
try:
url, clone_url = self.create_repository(
credentials,
input_data.name,
input_data.description,
input_data.private,
input_data.auto_init,
input_data.gitignore_template,
)
yield "url", url
yield "clone_url", clone_url
except Exception as e:
yield "error", str(e)
class GithubListStargazersBlock(Block):
class Input(BlockSchema):
credentials: GithubCredentialsInput = GithubCredentialsField("repo")
repo_url: str = SchemaField(
description="URL of the GitHub repository",
placeholder="https://github.com/owner/repo",
)
class Output(BlockSchema):
class StargazerItem(TypedDict):
username: str
url: str
stargazer: StargazerItem = SchemaField(
title="Stargazer",
description="Stargazers with their username and profile URL",
)
error: str = SchemaField(
description="Error message if listing stargazers failed"
)
def __init__(self):
super().__init__(
id="a4b9c2d1-e5f6-4g7h-8i9j-0k1l2m3n4o5p", # Generated unique UUID
description="This block lists all users who have starred a specified GitHub repository.",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=GithubListStargazersBlock.Input,
output_schema=GithubListStargazersBlock.Output,
test_input={
"repo_url": "https://github.com/owner/repo",
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"stargazer",
{
"username": "octocat",
"url": "https://github.com/octocat",
},
)
],
test_mock={
"list_stargazers": lambda *args, **kwargs: [
{
"username": "octocat",
"url": "https://github.com/octocat",
}
]
},
)
@staticmethod
def list_stargazers(
credentials: GithubCredentials, repo_url: str
) -> list[Output.StargazerItem]:
api = get_api(credentials)
# Add /stargazers to the repo URL to get stargazers endpoint
stargazers_url = f"{repo_url}/stargazers"
# Set accept header to get starred_at timestamp
headers = {"Accept": "application/vnd.github.star+json"}
response = api.get(stargazers_url, headers=headers)
data = response.json()
stargazers: list[GithubListStargazersBlock.Output.StargazerItem] = [
{
"username": stargazer["login"],
"url": stargazer["html_url"],
}
for stargazer in data
]
return stargazers
def run(
self,
input_data: Input,
*,
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
try:
stargazers = self.list_stargazers(
credentials,
input_data.repo_url,
)
yield from (("stargazer", stargazer) for stargazer in stargazers)
except Exception as e:
yield "error", str(e)

View File

@@ -0,0 +1,32 @@
from typing import Literal
from pydantic import SecretStr
from backend.data.model import APIKeyCredentials, CredentialsField, CredentialsMetaInput
from backend.integrations.providers import ProviderName
NvidiaCredentials = APIKeyCredentials
NvidiaCredentialsInput = CredentialsMetaInput[
Literal[ProviderName.NVIDIA],
Literal["api_key"],
]
TEST_CREDENTIALS = APIKeyCredentials(
id="01234567-89ab-cdef-0123-456789abcdef",
provider="nvidia",
api_key=SecretStr("mock-nvidia-api-key"),
title="Mock Nvidia API key",
expires_at=None,
)
TEST_CREDENTIALS_INPUT = {
"provider": TEST_CREDENTIALS.provider,
"id": TEST_CREDENTIALS.id,
"type": TEST_CREDENTIALS.type,
"title": TEST_CREDENTIALS.title,
}
def NvidiaCredentialsField() -> NvidiaCredentialsInput:
"""Creates an Nvidia credentials input on a block."""
return CredentialsField(description="The Nvidia integration requires an API Key.")

View File

@@ -0,0 +1,90 @@
from backend.blocks.nvidia._auth import (
NvidiaCredentials,
NvidiaCredentialsField,
NvidiaCredentialsInput,
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
class NvidiaDeepfakeDetectBlock(Block):
class Input(BlockSchema):
credentials: NvidiaCredentialsInput = NvidiaCredentialsField()
image_base64: str = SchemaField(
description="Image to analyze for deepfakes", image_upload=True
)
return_image: bool = SchemaField(
description="Whether to return the processed image with markings",
default=False,
)
class Output(BlockSchema):
status: str = SchemaField(
description="Detection status (SUCCESS, ERROR, CONTENT_FILTERED)",
default="",
)
image: str = SchemaField(
description="Processed image with detection markings (if return_image=True)",
default="",
image_output=True,
)
is_deepfake: float = SchemaField(
description="Probability that the image is a deepfake (0-1)",
default=0.0,
)
def __init__(self):
super().__init__(
id="8c7d0d67-e79c-44f6-92a1-c2600c8aac7f",
description="Detects potential deepfakes in images using Nvidia's AI API",
categories={BlockCategory.SAFETY},
input_schema=NvidiaDeepfakeDetectBlock.Input,
output_schema=NvidiaDeepfakeDetectBlock.Output,
)
def run(
self, input_data: Input, *, credentials: NvidiaCredentials, **kwargs
) -> BlockOutput:
url = "https://ai.api.nvidia.com/v1/cv/hive/deepfake-image-detection"
headers = {
"accept": "application/json",
"content-type": "application/json",
"Authorization": f"Bearer {credentials.api_key.get_secret_value()}",
}
image_data = f"data:image/jpeg;base64,{input_data.image_base64}"
payload = {
"input": [image_data],
"return_image": input_data.return_image,
}
try:
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
result = data.get("data", [{}])[0]
# Get deepfake probability from first bounding box if any
deepfake_prob = 0.0
if result.get("bounding_boxes"):
deepfake_prob = result["bounding_boxes"][0].get("is_deepfake", 0.0)
yield "status", result.get("status", "ERROR")
yield "is_deepfake", deepfake_prob
if input_data.return_image:
image_data = result.get("image", "")
output_data = f"data:image/jpeg;base64,{image_data}"
yield "image", output_data
else:
yield "image", ""
except Exception as e:
yield "error", str(e)
yield "status", "ERROR"
yield "is_deepfake", 0.0
yield "image", ""

View File

@@ -22,10 +22,10 @@ from backend.util import json
from backend.util.settings import Config
from .model import (
CREDENTIALS_FIELD_NAME,
ContributorDetails,
Credentials,
CredentialsMetaInput,
is_credentials_field_name,
)
app_config = Config()
@@ -61,6 +61,9 @@ class BlockCategory(Enum):
HARDWARE = "Block that interacts with hardware."
AGENT = "Block that interacts with other agents."
CRM = "Block that interacts with CRM services."
SAFETY = (
"Block that provides AI safety mechanisms such as detecting harmful content"
)
def dict(self) -> dict[str, str]:
return {"category": self.name, "description": self.value}
@@ -97,11 +100,6 @@ class BlockSchema(BaseModel):
cls.cached_jsonschema = cast(dict[str, Any], ref_to_dict(model))
# Set default properties values
for field in cls.cached_jsonschema.get("properties", {}).values():
if isinstance(field, dict) and "advanced" not in field:
field["advanced"] = True
return cls.cached_jsonschema
@classmethod
@@ -143,17 +141,38 @@ class BlockSchema(BaseModel):
@classmethod
def __pydantic_init_subclass__(cls, **kwargs):
"""Validates the schema definition. Rules:
- Only one `CredentialsMetaInput` field may be present.
- This field MUST be called `credentials`.
- A field that is called `credentials` MUST be a `CredentialsMetaInput`.
- Fields with annotation `CredentialsMetaInput` MUST be
named `credentials` or `*_credentials`
- Fields named `credentials` or `*_credentials` MUST be
of type `CredentialsMetaInput`
"""
super().__pydantic_init_subclass__(**kwargs)
# Reset cached JSON schema to prevent inheriting it from parent class
cls.cached_jsonschema = {}
credentials_fields = [
field_name
credentials_fields = cls.get_credentials_fields()
for field_name in cls.get_fields():
if is_credentials_field_name(field_name):
if field_name not in credentials_fields:
raise TypeError(
f"Credentials field '{field_name}' on {cls.__qualname__} "
f"is not of type {CredentialsMetaInput.__name__}"
)
credentials_fields[field_name].validate_credentials_field_schema(cls)
elif field_name in credentials_fields:
raise KeyError(
f"Credentials field '{field_name}' on {cls.__qualname__} "
"has invalid name: must be 'credentials' or *_credentials"
)
@classmethod
def get_credentials_fields(cls) -> dict[str, type[CredentialsMetaInput]]:
return {
field_name: info.annotation
for field_name, info in cls.model_fields.items()
if (
inspect.isclass(info.annotation)
@@ -162,32 +181,7 @@ class BlockSchema(BaseModel):
CredentialsMetaInput,
)
)
]
if len(credentials_fields) > 1:
raise ValueError(
f"{cls.__qualname__} can only have one CredentialsMetaInput field"
)
elif (
len(credentials_fields) == 1
and credentials_fields[0] != CREDENTIALS_FIELD_NAME
):
raise ValueError(
f"CredentialsMetaInput field on {cls.__qualname__} "
"must be named 'credentials'"
)
elif (
len(credentials_fields) == 0
and CREDENTIALS_FIELD_NAME in cls.model_fields.keys()
):
raise TypeError(
f"Field 'credentials' on {cls.__qualname__} "
f"must be of type {CredentialsMetaInput.__name__}"
)
if credentials_field := cls.model_fields.get(CREDENTIALS_FIELD_NAME):
credentials_input_type = cast(
CredentialsMetaInput, credentials_field.annotation
)
credentials_input_type.validate_credentials_field_schema(cls)
}
BlockSchemaInputType = TypeVar("BlockSchemaInputType", bound=BlockSchema)
@@ -260,7 +254,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
test_input: BlockInput | list[BlockInput] | None = None,
test_output: BlockData | list[BlockData] | None = None,
test_mock: dict[str, Any] | None = None,
test_credentials: Optional[Credentials] = None,
test_credentials: Optional[Credentials | dict[str, Credentials]] = None,
disabled: bool = False,
static_output: bool = False,
block_type: BlockType = BlockType.STANDARD,
@@ -302,10 +296,16 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
if self.webhook_config:
if isinstance(self.webhook_config, BlockWebhookConfig):
# Enforce presence of credentials field on auto-setup webhook blocks
if CREDENTIALS_FIELD_NAME not in self.input_schema.model_fields:
if not (cred_fields := self.input_schema.get_credentials_fields()):
raise TypeError(
"credentials field is required on auto-setup webhook blocks"
)
# Disallow multiple credentials inputs on webhook blocks
elif len(cred_fields) > 1:
raise ValueError(
"Multiple credentials inputs not supported on webhook blocks"
)
self.block_type = BlockType.WEBHOOK
else:
self.block_type = BlockType.WEBHOOK_MANUAL

View File

@@ -270,9 +270,9 @@ async def update_graph_execution_start_time(graph_exec_id: str):
async def update_graph_execution_stats(
graph_exec_id: str,
status: ExecutionStatus,
stats: dict[str, Any],
) -> ExecutionResult:
status = ExecutionStatus.FAILED if stats.get("error") else ExecutionStatus.COMPLETED
res = await AgentGraphExecution.prisma().update(
where={"id": graph_exec_id},
data={

View File

@@ -193,7 +193,8 @@ class Graph(BaseDbModel):
"properties": {
p.name: {
"secret": p.secret,
"advanced": p.advanced,
# Default value has to be set for advanced fields.
"advanced": p.advanced and p.value is not None,
"title": p.title or p.name,
**({"description": p.description} if p.description else {}),
**({"default": p.value} if p.value is not None else {}),
@@ -423,6 +424,26 @@ class GraphModel(Graph):
result[key] = value
return result
def clean_graph(self):
blocks = [block() for block in get_blocks().values()]
input_blocks = [
node
for node in self.nodes
if next(
(
b
for b in blocks
if b.id == node.block_id and b.block_type == BlockType.INPUT
),
None,
)
]
for node in self.nodes:
if any(input_block.id == node.id for input_block in input_blocks):
node.input_default["value"] = ""
# --------------------- CRUD functions --------------------- #
@@ -608,25 +629,20 @@ async def __create_graph(tx, graph: Graph, user_id: str):
"isTemplate": graph.is_template,
"isActive": graph.is_active,
"userId": user_id,
"AgentNodes": {
"create": [
{
"id": node.id,
"agentBlockId": node.block_id,
"constantInput": json.dumps(node.input_default),
"metadata": json.dumps(node.metadata),
}
for node in graph.nodes
]
},
}
)
await asyncio.gather(
*[
AgentNode.prisma(tx).create(
{
"id": node.id,
"agentBlockId": node.block_id,
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"constantInput": json.dumps(node.input_default),
"metadata": json.dumps(node.metadata),
}
)
for node in graph.nodes
]
)
await asyncio.gather(
*[
AgentNodeLink.prisma(tx).create(

View File

@@ -35,7 +35,7 @@ class Webhook(BaseDbModel):
@computed_field
@property
def url(self) -> str:
return webhook_ingress_url(self.provider.value, self.id)
return webhook_ingress_url(self.provider, self.id)
@staticmethod
def from_db(webhook: IntegrationWebhook):

View File

@@ -134,13 +134,20 @@ def SchemaField(
title: Optional[str] = None,
description: Optional[str] = None,
placeholder: Optional[str] = None,
advanced: Optional[bool] = False,
advanced: Optional[bool] = None,
secret: bool = False,
exclude: bool = False,
hidden: Optional[bool] = None,
depends_on: list[str] | None = None,
image_upload: Optional[bool] = None,
image_output: Optional[bool] = None,
**kwargs,
) -> T:
if default is PydanticUndefined and default_factory is None:
advanced = False
elif advanced is None:
advanced = True
json_extra = {
k: v
for k, v in {
@@ -149,6 +156,8 @@ def SchemaField(
"advanced": advanced,
"hidden": hidden,
"depends_on": depends_on,
"image_upload": image_upload,
"image_output": image_output,
}.items()
if v is not None
}
@@ -240,7 +249,8 @@ CP = TypeVar("CP", bound=ProviderName)
CT = TypeVar("CT", bound=CredentialsType)
CREDENTIALS_FIELD_NAME = "credentials"
def is_credentials_field_name(field_name: str) -> bool:
return field_name == "credentials" or field_name.endswith("_credentials")
class CredentialsMetaInput(BaseModel, Generic[CP, CT]):
@@ -249,21 +259,21 @@ class CredentialsMetaInput(BaseModel, Generic[CP, CT]):
provider: CP
type: CT
@staticmethod
def _add_json_schema_extra(schema, cls: CredentialsMetaInput):
schema["credentials_provider"] = get_args(
cls.model_fields["provider"].annotation
)
schema["credentials_types"] = get_args(cls.model_fields["type"].annotation)
@classmethod
def allowed_providers(cls) -> tuple[ProviderName, ...]:
return get_args(cls.model_fields["provider"].annotation)
model_config = ConfigDict(
json_schema_extra=_add_json_schema_extra, # type: ignore
)
@classmethod
def allowed_cred_types(cls) -> tuple[CredentialsType, ...]:
return get_args(cls.model_fields["type"].annotation)
@classmethod
def validate_credentials_field_schema(cls, model: type["BlockSchema"]):
"""Validates the schema of a `credentials` field"""
field_schema = model.jsonschema()["properties"][CREDENTIALS_FIELD_NAME]
"""Validates the schema of a credentials input field"""
field_name = next(
name for name, type in model.get_credentials_fields().items() if type is cls
)
field_schema = model.jsonschema()["properties"][field_name]
try:
schema_extra = _CredentialsFieldSchemaExtra[CP, CT].model_validate(
field_schema
@@ -277,11 +287,20 @@ class CredentialsMetaInput(BaseModel, Generic[CP, CT]):
f"{field_schema}"
) from e
if (
len(schema_extra.credentials_provider) > 1
and not schema_extra.discriminator
):
raise TypeError("Multi-provider CredentialsField requires discriminator!")
if len(cls.allowed_providers()) > 1 and not schema_extra.discriminator:
raise TypeError(
f"Multi-provider CredentialsField '{field_name}' "
"requires discriminator!"
)
@staticmethod
def _add_json_schema_extra(schema, cls: CredentialsMetaInput):
schema["credentials_provider"] = cls.allowed_providers()
schema["credentials_types"] = cls.allowed_cred_types()
model_config = ConfigDict(
json_schema_extra=_add_json_schema_extra, # type: ignore
)
class _CredentialsFieldSchemaExtra(BaseModel, Generic[CP, CT]):

View File

@@ -10,7 +10,6 @@ from contextlib import contextmanager
from multiprocessing.pool import AsyncResult, Pool
from typing import TYPE_CHECKING, Any, Generator, TypeVar, cast
from pydantic import BaseModel
from redis.lock import Lock as RedisLock
if TYPE_CHECKING:
@@ -20,7 +19,14 @@ from autogpt_libs.utils.cache import thread_cached
from backend.blocks.agent import AgentExecutorBlock
from backend.data import redis
from backend.data.block import Block, BlockData, BlockInput, BlockType, get_block
from backend.data.block import (
Block,
BlockData,
BlockInput,
BlockSchema,
BlockType,
get_block,
)
from backend.data.execution import (
ExecutionQueue,
ExecutionResult,
@@ -31,7 +37,6 @@ from backend.data.execution import (
parse_execution_output,
)
from backend.data.graph import GraphModel, Link, Node
from backend.data.model import CREDENTIALS_FIELD_NAME, CredentialsMetaInput
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.util import json
from backend.util.decorator import error_logged, time_measured
@@ -170,10 +175,11 @@ def execute_node(
# one (running) block at a time; simultaneous execution of blocks using same
# credentials is not supported.
creds_lock = None
if CREDENTIALS_FIELD_NAME in input_data:
credentials_meta = CredentialsMetaInput(**input_data[CREDENTIALS_FIELD_NAME])
input_model = cast(type[BlockSchema], node_block.input_schema)
for field_name, input_type in input_model.get_credentials_fields().items():
credentials_meta = input_type(**input_data[field_name])
credentials, creds_lock = creds_manager.acquire(user_id, credentials_meta.id)
extra_exec_kwargs["credentials"] = credentials
extra_exec_kwargs[field_name] = credentials
output_size = 0
end_status = ExecutionStatus.COMPLETED
@@ -591,7 +597,7 @@ class Executor:
node_eid="*",
block_name="-",
)
timing_info, (exec_stats, error) = cls._on_graph_execution(
timing_info, (exec_stats, status, error) = cls._on_graph_execution(
graph_exec, cancel, log_metadata
)
exec_stats["walltime"] = timing_info.wall_time
@@ -599,6 +605,7 @@ class Executor:
exec_stats["error"] = str(error) if error else None
result = cls.db_client.update_graph_execution_stats(
graph_exec_id=graph_exec.graph_exec_id,
status=status,
stats=exec_stats,
)
cls.db_client.send_execution_update(result)
@@ -610,11 +617,12 @@ class Executor:
graph_exec: GraphExecutionEntry,
cancel: threading.Event,
log_metadata: LogMetadata,
) -> tuple[dict[str, Any], Exception | None]:
) -> tuple[dict[str, Any], ExecutionStatus, Exception | None]:
"""
Returns:
The execution statistics of the graph execution.
The error that occurred during the execution.
dict: The execution statistics of the graph execution.
ExecutionStatus: The final status of the graph execution.
Exception | None: The error that occurred during the execution, if any.
"""
log_metadata.info(f"Start graph execution {graph_exec.graph_exec_id}")
exec_stats = {
@@ -659,8 +667,7 @@ class Executor:
while not queue.empty():
if cancel.is_set():
error = RuntimeError("Execution is cancelled")
return exec_stats, error
return exec_stats, ExecutionStatus.TERMINATED, error
exec_data = queue.get()
@@ -690,8 +697,7 @@ class Executor:
)
for node_id, execution in list(running_executions.items()):
if cancel.is_set():
error = RuntimeError("Execution is cancelled")
return exec_stats, error
return exec_stats, ExecutionStatus.TERMINATED, error
if not queue.empty():
break # yield to parent loop to execute new queue items
@@ -710,7 +716,12 @@ class Executor:
finished = True
cancel.set()
cancel_thread.join()
return exec_stats, error
return (
exec_stats,
ExecutionStatus.FAILED if error else ExecutionStatus.COMPLETED,
error,
)
class ExecutionManager(AppService):
@@ -876,11 +887,8 @@ class ExecutionManager(AppService):
ExecutionStatus.COMPLETED,
ExecutionStatus.FAILED,
):
self.db_client.upsert_execution_output(
node_exec.node_exec_id, "error", "TERMINATED"
)
exec_update = self.db_client.update_execution_status(
node_exec.node_exec_id, ExecutionStatus.FAILED
node_exec.node_exec_id, ExecutionStatus.TERMINATED
)
self.db_client.send_execution_update(exec_update)
@@ -893,41 +901,39 @@ class ExecutionManager(AppService):
raise ValueError(f"Unknown block {node.block_id} for node #{node.id}")
# Find any fields of type CredentialsMetaInput
model_fields = cast(type[BaseModel], block.input_schema).model_fields
if CREDENTIALS_FIELD_NAME not in model_fields:
credentials_fields = cast(
type[BlockSchema], block.input_schema
).get_credentials_fields()
if not credentials_fields:
continue
field = model_fields[CREDENTIALS_FIELD_NAME]
# The BlockSchema class enforces that a `credentials` field is always a
# `CredentialsMetaInput`, so we can safely assume this here.
credentials_meta_type = cast(CredentialsMetaInput, field.annotation)
credentials_meta = credentials_meta_type.model_validate(
node.input_default[CREDENTIALS_FIELD_NAME]
)
# Fetch the corresponding Credentials and perform sanity checks
credentials = self.credentials_store.get_creds_by_id(
user_id, credentials_meta.id
)
if not credentials:
raise ValueError(
f"Unknown credentials #{credentials_meta.id} "
f"for node #{node.id}"
for field_name, credentials_meta_type in credentials_fields.items():
credentials_meta = credentials_meta_type.model_validate(
node.input_default[field_name]
)
if (
credentials.provider != credentials_meta.provider
or credentials.type != credentials_meta.type
):
logger.warning(
f"Invalid credentials #{credentials.id} for node #{node.id}: "
"type/provider mismatch: "
f"{credentials_meta.type}<>{credentials.type};"
f"{credentials_meta.provider}<>{credentials.provider}"
)
raise ValueError(
f"Invalid credentials #{credentials.id} for node #{node.id}: "
"type/provider mismatch"
# Fetch the corresponding Credentials and perform sanity checks
credentials = self.credentials_store.get_creds_by_id(
user_id, credentials_meta.id
)
if not credentials:
raise ValueError(
f"Unknown credentials #{credentials_meta.id} "
f"for node #{node.id} input '{field_name}'"
)
if (
credentials.provider != credentials_meta.provider
or credentials.type != credentials_meta.type
):
logger.warning(
f"Invalid credentials #{credentials.id} for node #{node.id}: "
"type/provider mismatch: "
f"{credentials_meta.type}<>{credentials.type};"
f"{credentials_meta.provider}<>{credentials.provider}"
)
raise ValueError(
f"Invalid credentials #{credentials.id} for node #{node.id}: "
"type/provider mismatch"
)
# ------- UTILITIES ------- #
@@ -947,7 +953,8 @@ def synchronized(key: str, timeout: int = 60):
lock.acquire()
yield
finally:
lock.release()
if lock.locked():
lock.release()
def llprint(message: str):

View File

@@ -99,6 +99,10 @@ class ExecutionScheduler(AppService):
def get_port(cls) -> int:
return config.execution_scheduler_port
@classmethod
def db_pool_size(cls) -> int:
return config.scheduler_db_pool_size
@property
@thread_cached
def execution_client(self) -> ExecutionManager:
@@ -110,7 +114,11 @@ class ExecutionScheduler(AppService):
self.scheduler = BlockingScheduler(
jobstores={
"default": SQLAlchemyJobStore(
engine=create_engine(db_url),
engine=create_engine(
url=db_url,
pool_size=self.db_pool_size(),
max_overflow=0,
),
metadata=MetaData(schema=db_schema),
)
}

View File

@@ -92,7 +92,7 @@ class IntegrationCredentialsManager:
fresh_credentials = oauth_handler.refresh_tokens(credentials)
self.store.update_creds(user_id, fresh_credentials)
if _lock:
if _lock and _lock.locked():
_lock.release()
credentials = fresh_credentials
@@ -144,7 +144,8 @@ class IntegrationCredentialsManager:
try:
yield
finally:
lock.release()
if lock.locked():
lock.release()
def release_all_locks(self):
"""Call this on process termination to ensure all locks are released"""

View File

@@ -19,6 +19,7 @@ class ProviderName(str, Enum):
JINA = "jina"
MEDIUM = "medium"
NOTION = "notion"
NVIDIA = "nvidia"
OLLAMA = "ollama"
OPENAI = "openai"
OPENWEATHERMAP = "openweathermap"

View File

@@ -1,9 +1,8 @@
import logging
from typing import TYPE_CHECKING, Callable, Optional, cast
from backend.data.block import BlockWebhookConfig, get_block
from backend.data.block import BlockSchema, BlockWebhookConfig, get_block
from backend.data.graph import set_node_webhook
from backend.data.model import CREDENTIALS_FIELD_NAME
from backend.integrations.webhooks import WEBHOOK_MANAGERS_BY_NAME
if TYPE_CHECKING:
@@ -30,14 +29,28 @@ async def on_graph_activate(
# Compare nodes in new_graph_version with previous_graph_version
updated_nodes = []
for new_node in graph.nodes:
block = get_block(new_node.block_id)
if not block:
raise ValueError(
f"Node #{new_node.id} is instance of unknown block #{new_node.block_id}"
)
block_input_schema = cast(BlockSchema, block.input_schema)
node_credentials = None
if creds_meta := new_node.input_default.get(CREDENTIALS_FIELD_NAME):
node_credentials = get_credentials(creds_meta["id"])
if not node_credentials:
raise ValueError(
f"Node #{new_node.id} updated with non-existent "
f"credentials #{node_credentials}"
if (
# Webhook-triggered blocks are only allowed to have 1 credentials input
(
creds_field_name := next(
iter(block_input_schema.get_credentials_fields()), None
)
)
and (creds_meta := new_node.input_default.get(creds_field_name))
and not (node_credentials := get_credentials(creds_meta["id"]))
):
raise ValueError(
f"Node #{new_node.id} input '{creds_field_name}' updated with "
f"non-existent credentials #{creds_meta['id']}"
)
updated_node = await on_node_activate(
graph.user_id, new_node, credentials=node_credentials
@@ -62,14 +75,28 @@ async def on_graph_deactivate(
"""
updated_nodes = []
for node in graph.nodes:
block = get_block(node.block_id)
if not block:
raise ValueError(
f"Node #{node.id} is instance of unknown block #{node.block_id}"
)
block_input_schema = cast(BlockSchema, block.input_schema)
node_credentials = None
if creds_meta := node.input_default.get(CREDENTIALS_FIELD_NAME):
node_credentials = get_credentials(creds_meta["id"])
if not node_credentials:
logger.error(
f"Node #{node.id} referenced non-existent "
f"credentials #{creds_meta['id']}"
if (
# Webhook-triggered blocks are only allowed to have 1 credentials input
(
creds_field_name := next(
iter(block_input_schema.get_credentials_fields()), None
)
)
and (creds_meta := node.input_default.get(creds_field_name))
and not (node_credentials := get_credentials(creds_meta["id"]))
):
logger.error(
f"Node #{node.id} input '{creds_field_name}' referenced non-existent "
f"credentials #{creds_meta['id']}"
)
updated_node = await on_node_deactivate(node, credentials=node_credentials)
updated_nodes.append(updated_node)
@@ -119,14 +146,17 @@ async def on_node_activate(
else:
resource = "" # not relevant for manual webhooks
needs_credentials = CREDENTIALS_FIELD_NAME in block.input_schema.model_fields
block_input_schema = cast(BlockSchema, block.input_schema)
credentials_field_name = next(iter(block_input_schema.get_credentials_fields()), "")
credentials_meta = (
node.input_default.get(CREDENTIALS_FIELD_NAME) if needs_credentials else None
node.input_default.get(credentials_field_name)
if credentials_field_name
else None
)
event_filter_input_name = block.webhook_config.event_filter_input
has_everything_for_webhook = (
resource is not None
and (credentials_meta or not needs_credentials)
and (credentials_meta or not credentials_field_name)
and (
not event_filter_input_name
or (
@@ -230,7 +260,7 @@ async def on_node_deactivate(
)
await webhooks_manager.prune_webhook_if_dangling(webhook.id, credentials)
if (
CREDENTIALS_FIELD_NAME in block.input_schema.model_fields
cast(BlockSchema, block.input_schema).get_credentials_fields()
and not credentials
):
logger.warning(

View File

@@ -1,11 +1,12 @@
from backend.integrations.providers import ProviderName
from backend.util.settings import Config
app_config = Config()
# TODO: add test to assert this matches the actual API route
def webhook_ingress_url(provider_name: str, webhook_id: str) -> str:
def webhook_ingress_url(provider_name: ProviderName, webhook_id: str) -> str:
return (
f"{app_config.platform_base_url}/api/integrations/{provider_name}"
f"{app_config.platform_base_url}/api/integrations/{provider_name.value}"
f"/webhooks/{webhook_id}/ingress"
)

View File

@@ -541,7 +541,7 @@ def get_execution_schedules(
@v1_router.post(
"/api-keys",
response_model=list[CreateAPIKeyResponse] | dict[str, str],
response_model=CreateAPIKeyResponse,
tags=["api-keys"],
dependencies=[Depends(auth_middleware)],
)
@@ -583,7 +583,7 @@ async def get_api_keys(
@v1_router.get(
"/api-keys/{key_id}",
response_model=list[APIKeyWithoutHash] | dict[str, str],
response_model=APIKeyWithoutHash,
tags=["api-keys"],
dependencies=[Depends(auth_middleware)],
)
@@ -604,7 +604,7 @@ async def get_api_key(
@v1_router.delete(
"/api-keys/{key_id}",
response_model=list[APIKeyWithoutHash] | dict[str, str],
response_model=APIKeyWithoutHash,
tags=["api-keys"],
dependencies=[Depends(auth_middleware)],
)
@@ -626,7 +626,7 @@ async def delete_api_key(
@v1_router.post(
"/api-keys/{key_id}/suspend",
response_model=list[APIKeyWithoutHash] | dict[str, str],
response_model=APIKeyWithoutHash,
tags=["api-keys"],
dependencies=[Depends(auth_middleware)],
)
@@ -648,7 +648,7 @@ async def suspend_key(
@v1_router.put(
"/api-keys/{key_id}/permissions",
response_model=list[APIKeyWithoutHash] | dict[str, str],
response_model=APIKeyWithoutHash,
tags=["api-keys"],
dependencies=[Depends(auth_middleware)],
)

View File

@@ -2,6 +2,7 @@ import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import fastapi.testclient
import pytest
import pytest_mock
import backend.server.v2.library.db
@@ -80,6 +81,7 @@ def test_get_library_agents_error(mocker: pytest_mock.MockFixture):
mock_db_call.assert_called_once_with("test-user-id")
@pytest.mark.skip(reason="Mocker Not implemented")
def test_add_agent_to_library_success(mocker: pytest_mock.MockFixture):
mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library")
mock_db_call.return_value = None
@@ -91,6 +93,7 @@ def test_add_agent_to_library_success(mocker: pytest_mock.MockFixture):
)
@pytest.mark.skip(reason="Mocker Not implemented")
def test_add_agent_to_library_error(mocker: pytest_mock.MockFixture):
mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library")
mock_db_call.side_effect = Exception("Test error")

View File

@@ -1,14 +1,18 @@
import logging
import random
from datetime import datetime
from typing import Optional
import fastapi
import prisma.enums
import prisma.errors
import prisma.models
import prisma.types
import backend.data.graph
import backend.server.v2.store.exceptions
import backend.server.v2.store.model
from backend.data.graph import GraphModel
logger = logging.getLogger(__name__)
@@ -31,7 +35,7 @@ async def get_store_agents(
sanitized_query = search_query.strip()
if not sanitized_query or len(sanitized_query) > 100: # Reasonable length limit
raise backend.server.v2.store.exceptions.DatabaseError(
"Invalid search query"
f"Invalid search query: len({len(sanitized_query)}) query: {search_query}"
)
# Escape special SQL characters
@@ -449,6 +453,11 @@ async def create_store_submission(
)
try:
# Sanitize slug to only allow letters and hyphens
slug = "".join(
c if c.isalpha() or c == "-" or c.isnumeric() else "" for c in slug
).lower()
# First verify the agent belongs to this user
agent = await prisma.models.AgentGraph.prisma().find_first(
where=prisma.types.AgentGraphWhereInput(
@@ -636,7 +645,12 @@ async def update_or_create_profile(
logger.info(f"Updating profile for user {user_id} data: {profile}")
try:
# Check if profile exists for user
# Sanitize username to only allow letters and hyphens
username = "".join(
c if c.isalpha() or c == "-" or c.isnumeric() else ""
for c in profile.username
).lower()
existing_profile = await prisma.models.Profile.prisma().find_first(
where={"userId": user_id}
)
@@ -651,7 +665,7 @@ async def update_or_create_profile(
data={
"userId": user_id,
"name": profile.name,
"username": profile.username.lower(),
"username": username,
"description": profile.description,
"links": profile.links or [],
"avatarUrl": profile.avatar_url,
@@ -676,7 +690,7 @@ async def update_or_create_profile(
if profile.name is not None:
update_data["name"] = profile.name
if profile.username is not None:
update_data["username"] = profile.username.lower()
update_data["username"] = username
if profile.description is not None:
update_data["description"] = profile.description
if profile.links is not None:
@@ -776,3 +790,45 @@ async def get_my_agents(
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch my agents"
) from e
async def get_agent(
store_listing_version_id: str, version_id: Optional[int]
) -> GraphModel:
"""Get agent using the version ID and store listing version ID."""
try:
store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}, include={"Agent": True}
)
)
if not store_listing_version or not store_listing_version.Agent:
raise fastapi.HTTPException(
status_code=404,
detail=f"Store listing version {store_listing_version_id} not found",
)
agent = store_listing_version.Agent
graph = await backend.data.graph.get_graph(
agent.id, agent.version, template=True
)
if not graph:
raise fastapi.HTTPException(
status_code=404, detail=f"Agent {agent.id} not found"
)
graph.version = 1
graph.is_template = False
graph.is_active = True
delattr(graph, "user_id")
return graph
except Exception as e:
logger.error(f"Error getting agent: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch agent"
) from e

View File

@@ -1,4 +1,6 @@
import json
import logging
import tempfile
import typing
import urllib.parse
@@ -6,7 +8,9 @@ import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import fastapi.responses
from fastapi.encoders import jsonable_encoder
import backend.data.block
import backend.data.graph
import backend.server.v2.store.db
import backend.server.v2.store.image_gen
@@ -23,12 +27,16 @@ router = fastapi.APIRouter()
##############################################
@router.get("/profile", tags=["store", "private"])
@router.get(
"/profile",
tags=["store", "private"],
response_model=backend.server.v2.store.model.ProfileDetails,
)
async def get_profile(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
) -> backend.server.v2.store.model.ProfileDetails:
):
"""
Get the profile details for the authenticated user.
"""
@@ -37,20 +45,24 @@ async def get_profile(
return profile
except Exception:
logger.exception("Exception occurred whilst getting user profile")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the user profile"},
)
@router.post(
"/profile",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.CreatorDetails,
)
async def update_or_create_profile(
profile: backend.server.v2.store.model.Profile,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.CreatorDetails:
):
"""
Update the store profile for the authenticated user.
@@ -71,7 +83,10 @@ async def update_or_create_profile(
return updated_profile
except Exception:
logger.exception("Exception occurred whilst updating profile")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while updating the user profile"},
)
##############################################
@@ -79,7 +94,11 @@ async def update_or_create_profile(
##############################################
@router.get("/agents", tags=["store", "public"])
@router.get(
"/agents",
tags=["store", "public"],
response_model=backend.server.v2.store.model.StoreAgentsResponse,
)
async def get_agents(
featured: bool = False,
creator: str | None = None,
@@ -88,7 +107,7 @@ async def get_agents(
category: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreAgentsResponse:
):
"""
Get a paginated list of agents from the store with optional filtering and sorting.
@@ -138,13 +157,18 @@ async def get_agents(
return agents
except Exception:
logger.exception("Exception occured whilst getting store agents")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the store agents"},
)
@router.get("/agents/{username}/{agent_name}", tags=["store", "public"])
async def get_agent(
username: str, agent_name: str
) -> backend.server.v2.store.model.StoreAgentDetails:
@router.get(
"/agents/{username}/{agent_name}",
tags=["store", "public"],
response_model=backend.server.v2.store.model.StoreAgentDetails,
)
async def get_agent(username: str, agent_name: str):
"""
This is only used on the AgentDetails Page
@@ -153,20 +177,26 @@ async def get_agent(
try:
username = urllib.parse.unquote(username).lower()
# URL decode the agent name since it comes from the URL path
agent_name = urllib.parse.unquote(agent_name)
agent_name = urllib.parse.unquote(agent_name).lower()
agent = await backend.server.v2.store.db.get_store_agent_details(
username=username, agent_name=agent_name
)
return agent
except Exception:
logger.exception("Exception occurred whilst getting store agent details")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={
"detail": "An error occurred while retrieving the store agent details"
},
)
@router.post(
"/agents/{username}/{agent_name}/review",
tags=["store"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreReview,
)
async def create_review(
username: str,
@@ -175,7 +205,7 @@ async def create_review(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.StoreReview:
):
"""
Create a review for a store agent.
@@ -202,7 +232,10 @@ async def create_review(
return created_review
except Exception:
logger.exception("Exception occurred whilst creating store review")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while creating the store review"},
)
##############################################
@@ -210,14 +243,18 @@ async def create_review(
##############################################
@router.get("/creators", tags=["store", "public"])
@router.get(
"/creators",
tags=["store", "public"],
response_model=backend.server.v2.store.model.CreatorsResponse,
)
async def get_creators(
featured: bool = False,
search_query: str | None = None,
sorted_by: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.CreatorsResponse:
):
"""
This is needed for:
- Home Page Featured Creators
@@ -251,11 +288,20 @@ async def get_creators(
return creators
except Exception:
logger.exception("Exception occurred whilst getting store creators")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the store creators"},
)
@router.get("/creator/{username}", tags=["store", "public"])
async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDetails:
@router.get(
"/creator/{username}",
tags=["store", "public"],
response_model=backend.server.v2.store.model.CreatorDetails,
)
async def get_creator(
username: str,
):
"""
Get the details of a creator
- Creator Details Page
@@ -268,7 +314,12 @@ async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDet
return creator
except Exception:
logger.exception("Exception occurred whilst getting creator details")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={
"detail": "An error occurred while retrieving the creator details"
},
)
############################################
@@ -278,31 +329,36 @@ async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDet
"/myagents",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.MyAgentsResponse,
)
async def get_my_agents(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
) -> backend.server.v2.store.model.MyAgentsResponse:
):
try:
agents = await backend.server.v2.store.db.get_my_agents(user_id)
return agents
except Exception:
logger.exception("Exception occurred whilst getting my agents")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the my agents"},
)
@router.delete(
"/submissions/{submission_id}",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=bool,
)
async def delete_submission(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
submission_id: str,
) -> bool:
):
"""
Delete a store listing submission.
@@ -321,13 +377,17 @@ async def delete_submission(
return result
except Exception:
logger.exception("Exception occurred whilst deleting store submission")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while deleting the store submission"},
)
@router.get(
"/submissions",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreSubmissionsResponse,
)
async def get_submissions(
user_id: typing.Annotated[
@@ -335,7 +395,7 @@ async def get_submissions(
],
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
):
"""
Get a paginated list of store submissions for the authenticated user.
@@ -368,20 +428,26 @@ async def get_submissions(
return listings
except Exception:
logger.exception("Exception occurred whilst getting store submissions")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={
"detail": "An error occurred while retrieving the store submissions"
},
)
@router.post(
"/submissions",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreSubmission,
)
async def create_submission(
submission_request: backend.server.v2.store.model.StoreSubmissionRequest,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.StoreSubmission:
):
"""
Create a new store listing submission.
@@ -411,7 +477,10 @@ async def create_submission(
return submission
except Exception:
logger.exception("Exception occurred whilst creating store submission")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while creating the store submission"},
)
@router.post(
@@ -424,7 +493,7 @@ async def upload_submission_media(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> str:
):
"""
Upload media (images/videos) for a store listing submission.
@@ -443,10 +512,11 @@ async def upload_submission_media(
user_id=user_id, file=file
)
return media_url
except Exception as e:
except Exception:
logger.exception("Exception occurred whilst uploading submission media")
raise fastapi.HTTPException(
status_code=500, detail=f"Failed to upload media file: {str(e)}"
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while uploading the media file"},
)
@@ -503,8 +573,72 @@ async def generate_image(
)
return fastapi.responses.JSONResponse(content={"image_url": image_url})
except Exception as e:
except Exception:
logger.exception("Exception occurred whilst generating submission image")
raise fastapi.HTTPException(
status_code=500, detail=f"Failed to generate image: {str(e)}"
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while generating the image"},
)
@router.get(
"/download/agents/{store_listing_version_id}",
tags=["store", "public"],
)
async def download_agent_file(
store_listing_version_id: str = fastapi.Path(
..., description="The ID of the agent to download"
),
version: typing.Optional[int] = fastapi.Query(
None, description="Specific version of the agent"
),
) -> fastapi.responses.FileResponse:
"""
Download the agent file by streaming its content.
Args:
agent_id (str): The ID of the agent to download.
version (Optional[int]): Specific version of the agent to download.
Returns:
StreamingResponse: A streaming response containing the agent's graph data.
Raises:
HTTPException: If the agent is not found or an unexpected error occurs.
"""
graph_data = await backend.server.v2.store.db.get_agent(
store_listing_version_id=store_listing_version_id, version_id=version
)
graph_data.clean_graph()
graph_date_dict = jsonable_encoder(graph_data)
def remove_credentials(obj):
if obj and isinstance(obj, dict):
if "credentials" in obj:
del obj["credentials"]
if "creds" in obj:
del obj["creds"]
for value in obj.values():
remove_credentials(value)
elif isinstance(obj, list):
for item in obj:
remove_credentials(item)
return obj
graph_date_dict = remove_credentials(graph_date_dict)
file_name = f"agent_{store_listing_version_id}_v{version or 'latest'}.json"
# Sending graph as a stream (similar to marketplace v1)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp_file:
tmp_file.write(json.dumps(graph_date_dict))
tmp_file.flush()
return fastapi.responses.FileResponse(
tmp_file.name, filename=file_name, media_type="application/json"
)

View File

@@ -12,6 +12,7 @@ from backend.util.settings import Config
# List of IP networks to block
BLOCKED_IP_NETWORKS = [
# --8<-- [start:BLOCKED_IP_NETWORKS]
# IPv4 Ranges
ipaddress.ip_network("0.0.0.0/8"), # "This" Network
ipaddress.ip_network("10.0.0.0/8"), # Private-Use
ipaddress.ip_network("127.0.0.0/8"), # Loopback
@@ -20,6 +21,11 @@ BLOCKED_IP_NETWORKS = [
ipaddress.ip_network("192.168.0.0/16"), # Private-Use
ipaddress.ip_network("224.0.0.0/4"), # Multicast
ipaddress.ip_network("240.0.0.0/4"), # Reserved for Future Use
# IPv6 Ranges
ipaddress.ip_network("::1/128"), # Loopback
ipaddress.ip_network("fc00::/7"), # Unique local addresses (ULA)
ipaddress.ip_network("fe80::/10"), # Link-local
ipaddress.ip_network("ff00::/8"), # Multicast
# --8<-- [end:BLOCKED_IP_NETWORKS]
]
@@ -27,18 +33,6 @@ ALLOWED_SCHEMES = ["http", "https"]
HOSTNAME_REGEX = re.compile(r"^[A-Za-z0-9.-]+$") # Basic DNS-safe hostname pattern
def _canonicalize_url(url: str) -> str:
# Strip spaces and trailing slashes
url = url.strip().strip("/")
# Ensure the URL starts with http:// or https://
if not url.startswith(("http://", "https://")):
url = "http://" + url
# Replace backslashes with forward slashes to avoid parsing ambiguities
url = url.replace("\\", "/")
return url
def _is_ip_blocked(ip: str) -> bool:
"""
Checks if the IP address is in a blocked network.
@@ -49,11 +43,16 @@ def _is_ip_blocked(ip: str) -> bool:
def validate_url(url: str, trusted_origins: list[str]) -> str:
"""
Validates the URL to prevent SSRF attacks by ensuring it does not point to a private
or untrusted IP address, unless whitelisted.
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.
"""
url = _canonicalize_url(url)
# Canonicalize URL
url = url.strip("/ ").replace("\\", "/")
parsed = urlparse(url)
if not parsed.scheme:
url = f"http://{url}"
parsed = urlparse(url)
# Check scheme
if parsed.scheme not in ALLOWED_SCHEMES:
@@ -61,7 +60,7 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
f"Scheme '{parsed.scheme}' is not allowed. Only HTTP/HTTPS are supported."
)
# Validate and IDNA encode the hostname
# Validate and IDNA encode hostname
if not parsed.hostname:
raise ValueError("Invalid URL: No hostname found.")
@@ -75,11 +74,11 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
if not HOSTNAME_REGEX.match(ascii_hostname):
raise ValueError("Hostname contains invalid characters.")
# Rebuild the URL with the normalized, IDNA-encoded hostname
# Rebuild URL with IDNA-encoded hostname
parsed = parsed._replace(netloc=ascii_hostname)
url = str(urlunparse(parsed))
# Check if hostname is a trusted origin (exact match)
# If hostname is trusted, skip IP-based checks
if ascii_hostname in trusted_origins:
return url
@@ -92,11 +91,12 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
if not ip_addresses:
raise ValueError(f"No IP addresses found for {ascii_hostname}")
# Check if any resolved IP address falls into blocked ranges
for ip in ip_addresses:
if _is_ip_blocked(ip):
# 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 private IP address {ip} for hostname {ascii_hostname} is not allowed."
f"Access to blocked or private IP address {ip_str} "
f"for hostname {ascii_hostname} is not allowed."
)
return url
@@ -104,7 +104,9 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
class Requests:
"""
A wrapper around the requests library that validates URLs before making requests.
A wrapper around the requests library that validates URLs before
making requests, preventing SSRF by blocking private networks and
other disallowed address spaces.
"""
def __init__(
@@ -128,13 +130,16 @@ class Requests:
def request(
self, method, url, headers=None, allow_redirects=False, *args, **kwargs
) -> req.Response:
# Merge any extra headers
if self.extra_headers is not None:
headers = {**(headers or {}), **self.extra_headers}
# Validate the URL (with optional extra validator)
url = validate_url(url, self.trusted_origins)
if self.extra_url_validator is not None:
url = self.extra_url_validator(url)
# Perform the request
response = req.request(
method,
url,

View File

@@ -153,6 +153,11 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
description="The name of the Google Cloud Storage bucket for media files",
)
scheduler_db_pool_size: int = Field(
default=3,
description="The pool size for the scheduler database connection pool",
)
@field_validator("platform_base_url", "frontend_base_url")
@classmethod
def validate_platform_base_url(cls, v: str, info: ValidationInfo) -> str:

View File

@@ -1,11 +1,11 @@
import logging
import time
from typing import Sequence
from typing import Sequence, cast
from backend.data import db
from backend.data.block import Block, initialize_blocks
from backend.data.block import Block, BlockSchema, initialize_blocks
from backend.data.execution import ExecutionResult, ExecutionStatus
from backend.data.model import CREDENTIALS_FIELD_NAME
from backend.data.model import _BaseCredentials
from backend.data.user import create_default_user
from backend.executor import DatabaseManager, ExecutionManager, ExecutionScheduler
from backend.server.rest_api import AgentServer
@@ -65,6 +65,9 @@ async def wait_execution(
if status == ExecutionStatus.FAILED:
log.info("Execution failed")
raise Exception("Execution failed")
if status == ExecutionStatus.TERMINATED:
log.info("Execution terminated")
raise Exception("Execution terminated")
return status == ExecutionStatus.COMPLETED
# Wait for the executions to complete
@@ -100,14 +103,22 @@ def execute_block_test(block: Block):
else:
log.info(f"{prefix} mock {mock_name} not found in block")
# Populate credentials argument(s)
extra_exec_kwargs = {}
if CREDENTIALS_FIELD_NAME in block.input_schema.model_fields:
if not block.test_credentials:
raise ValueError(
f"{prefix} requires credentials but has no test_credentials"
)
extra_exec_kwargs[CREDENTIALS_FIELD_NAME] = block.test_credentials
input_model = cast(type[BlockSchema], block.input_schema)
credentials_input_fields = input_model.get_credentials_fields()
if len(credentials_input_fields) == 1 and isinstance(
block.test_credentials, _BaseCredentials
):
field_name = next(iter(credentials_input_fields))
extra_exec_kwargs[field_name] = block.test_credentials
elif credentials_input_fields and block.test_credentials:
if not isinstance(block.test_credentials, dict):
raise TypeError(f"Block {block.name} has no usable test credentials")
else:
for field_name in credentials_input_fields:
if field_name in block.test_credentials:
extra_exec_kwargs[field_name] = block.test_credentials[field_name]
for input_data in block.test_input:
log.info(f"{prefix} in: {input_data}")

View File

@@ -0,0 +1,50 @@
BEGIN;
DROP VIEW IF EXISTS "StoreAgent";
CREATE VIEW "StoreAgent" AS
WITH ReviewStats AS (
SELECT sl."id" AS "storeListingId",
COUNT(sr.id) AS review_count,
AVG(CAST(sr.score AS DECIMAL)) AS avg_rating
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl."id"
JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
WHERE sl."isDeleted" = FALSE
GROUP BY sl."id"
),
AgentRuns AS (
SELECT "agentGraphId", COUNT(*) AS run_count
FROM "AgentGraphExecution"
GROUP BY "agentGraphId"
)
SELECT
sl.id AS listing_id,
slv.id AS "storeListingVersionId",
slv."createdAt" AS updated_at,
slv.slug,
slv.name AS agent_name,
slv."videoUrl" AS agent_video,
COALESCE(slv."imageUrls", ARRAY[]::TEXT[]) AS agent_image,
slv."isFeatured" AS featured,
p.username AS creator_username,
p."avatarUrl" AS creator_avatar,
slv."subHeading" AS sub_heading,
slv.description,
slv.categories,
COALESCE(ar.run_count, 0) AS runs,
CAST(COALESCE(rs.avg_rating, 0.0) AS DOUBLE PRECISION) AS rating,
ARRAY_AGG(DISTINCT CAST(slv.version AS TEXT)) AS versions
FROM "StoreListing" sl
JOIN "AgentGraph" a ON sl."agentId" = a.id AND sl."agentVersion" = a."version"
LEFT JOIN "Profile" p ON sl."owningUserId" = p."userId"
LEFT JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN ReviewStats rs ON sl.id = rs."storeListingId"
LEFT JOIN AgentRuns ar ON a.id = ar."agentGraphId"
WHERE sl."isDeleted" = FALSE
AND sl."isApproved" = TRUE
GROUP BY sl.id, slv.id, slv.slug, slv."createdAt", slv.name, slv."videoUrl", slv."imageUrls", slv."isFeatured",
p.username, p."avatarUrl", slv."subHeading", slv.description, slv.categories,
ar.run_count, rs.avg_rating;
COMMIT;

View File

@@ -0,0 +1,2 @@
-- Add "TERMINATED" to execution status enum type
ALTER TYPE "AgentExecutionStatus" ADD VALUE 'TERMINATED';

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ version = "0.3.4"
description = "A platform for building AI-powered agentic workflows"
authors = ["AutoGPT <info@agpt.co>"]
readme = "README.md"
packages = [{ include = "backend" }]
packages = [{ include = "backend", format = "sdist" }]
[tool.poetry.dependencies]
@@ -14,7 +14,6 @@ anthropic = "^0.40.0"
apscheduler = "^3.11.0"
autogpt-libs = { path = "../autogpt_libs", develop = true }
click = "^8.1.7"
croniter = "^5.0.1"
discord-py = "^2.4.0"
e2b-code-interpreter = "^1.0.1"
fastapi = "^0.115.5"

View File

@@ -216,6 +216,7 @@ enum AgentExecutionStatus {
QUEUED
RUNNING
COMPLETED
TERMINATED
FAILED
}
@@ -638,4 +639,4 @@ enum APIKeyStatus {
ACTIVE
REVOKED
SUSPENDED
}
}

View File

@@ -90,7 +90,12 @@ async def test_get_input_schema(server: SpinTestServer):
Node(
id="node_0_a",
block_id=input_block,
input_default={"name": "in_key_a", "title": "Key A", "value": "A"},
input_default={
"name": "in_key_a",
"title": "Key A",
"value": "A",
"advanced": True,
},
metadata={"id": "node_0_a"},
),
Node(
@@ -138,8 +143,8 @@ async def test_get_input_schema(server: SpinTestServer):
)
class ExpectedInputSchema(BlockSchema):
in_key_a: Any = SchemaField(title="Key A", default="A", advanced=False)
in_key_b: Any = SchemaField(title="in_key_b", advanced=True)
in_key_a: Any = SchemaField(title="Key A", default="A", advanced=True)
in_key_b: Any = SchemaField(title="in_key_b", advanced=False)
class ExpectedOutputSchema(BlockSchema):
out_key: Any = SchemaField(
@@ -155,3 +160,45 @@ async def test_get_input_schema(server: SpinTestServer):
output_schema = created_graph.output_schema
output_schema["title"] = "ExpectedOutputSchema"
assert output_schema == ExpectedOutputSchema.jsonschema()
@pytest.mark.asyncio(scope="session")
async def test_clean_graph(server: SpinTestServer):
"""
Test the clean_graph function that:
1. Clears input block values
2. Removes credentials from nodes
"""
# Create a graph with input blocks and credentials
graph = Graph(
id="test_clean_graph",
name="Test Clean Graph",
description="Test graph cleaning",
nodes=[
Node(
id="input_node",
block_id=AgentInputBlock().id,
input_default={
"name": "test_input",
"value": "test value",
"description": "Test input description",
},
),
],
links=[],
)
# Create graph and get model
create_graph = CreateGraph(graph=graph)
created_graph = await server.agent_server.test_create_graph(
create_graph, DEFAULT_USER_ID
)
# Clean the graph
created_graph.clean_graph()
# # Verify input block value is cleared
input_node = next(
n for n in created_graph.nodes if n.block_id == AgentInputBlock().id
)
assert input_node.input_default["value"] == ""

View File

@@ -144,51 +144,6 @@ services:
networks:
- app-network
market:
build:
context: ../
dockerfile: autogpt_platform/market/Dockerfile
develop:
watch:
- path: ./
target: autogpt_platform/market/
action: rebuild
depends_on:
db:
condition: service_healthy
market-migrations:
condition: service_completed_successfully
environment:
- SUPABASE_URL=http://kong:8000
- SUPABASE_JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
- SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
- DATABASE_URL=postgresql://postgres:your-super-secret-and-long-postgres-password@db:5432/postgres?connect_timeout=60&schema=market
- BACKEND_CORS_ALLOW_ORIGINS="http://localhost:3000,http://127.0.0.1:3000"
ports:
- "8015:8015"
networks:
- app-network
market-migrations:
build:
context: ../
dockerfile: autogpt_platform/market/Dockerfile
command: ["sh", "-c", "poetry run prisma migrate deploy"]
develop:
watch:
- path: ./
target: autogpt_platform/market/
action: rebuild
depends_on:
db:
condition: service_healthy
environment:
- SUPABASE_URL=http://kong:8000
- SUPABASE_JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
- SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
- DATABASE_URL=postgresql://postgres:your-super-secret-and-long-postgres-password@db:5432/postgres?connect_timeout=60&schema=market
networks:
- app-network
# frontend:
# build:
# context: ../

View File

@@ -51,18 +51,6 @@ services:
file: ./docker-compose.platform.yml
service: websocket_server
market:
<<: *agpt-services
extends:
file: ./docker-compose.platform.yml
service: market
market-migrations:
<<: *agpt-services
extends:
file: ./docker-compose.platform.yml
service: market-migrations
# frontend:
# <<: *agpt-services
# extends:

View File

@@ -24,8 +24,8 @@
],
"dependencies": {
"@faker-js/faker": "^9.3.0",
"@hookform/resolvers": "^3.9.1",
"@next/third-parties": "^15.0.4",
"@hookform/resolvers": "^3.10.0",
"@next/third-parties": "^15.1.3",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
@@ -59,29 +59,29 @@
"dotenv": "^16.4.7",
"elliptic": "6.6.1",
"embla-carousel-react": "^8.3.0",
"framer-motion": "^11.15.0",
"framer-motion": "^11.16.0",
"geist": "^1.3.1",
"launchdarkly-react-client-sdk": "^3.6.0",
"lucide-react": "^0.468.0",
"lucide-react": "^0.469.0",
"moment": "^2.30.1",
"next": "^14.2.13",
"next-themes": "^0.4.4",
"react": "^18",
"react-day-picker": "^9.4.4",
"react-day-picker": "^9.5.0",
"react-dom": "^18",
"react-hook-form": "^7.54.0",
"react-hook-form": "^7.54.2",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-modal": "^3.16.1",
"react-markdown": "^9.0.3",
"react-modal": "^3.16.3",
"react-shepherd": "^6.1.6",
"recharts": "^2.14.1",
"tailwind-merge": "^2.5.5",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.3",
"uuid": "^11.0.4",
"zod": "^3.23.8"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.2",
"@chromatic-com/storybook": "^3.2.3",
"@playwright/test": "^1.48.2",
"@storybook/addon-a11y": "^8.3.5",
"@storybook/addon-essentials": "^8.4.2",
@@ -92,25 +92,25 @@
"@storybook/nextjs": "^8.4.2",
"@storybook/react": "^8.3.5",
"@storybook/test": "^8.3.5",
"@storybook/test-runner": "^0.20.1",
"@storybook/test-runner": "^0.21.0",
"@types/negotiator": "^0.6.3",
"@types/node": "^22.9.0",
"@types/node": "^22.10.5",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-modal": "^3.16.3",
"axe-playwright": "^2.0.3",
"chromatic": "^11.12.5",
"concurrently": "^9.0.1",
"chromatic": "^11.22.0",
"concurrently": "^9.1.2",
"eslint": "^8",
"eslint-config-next": "15.1.0",
"eslint-plugin-storybook": "^0.11.0",
"eslint-config-next": "15.1.3",
"eslint-plugin-storybook": "^0.11.2",
"msw": "^2.7.0",
"msw-storybook-addon": "^2.0.3",
"postcss": "^8",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.9",
"storybook": "^8.4.5",
"tailwindcss": "^3.4.15",
"tailwindcss": "^3.4.17",
"typescript": "^5"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",

View File

@@ -1,6 +1,6 @@
import React from "react";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Inter, Poppins } from "next/font/google";
import { Providers } from "@/app/providers";
import { cn } from "@/lib/utils";
import { Navbar } from "@/components/agptui/Navbar";
@@ -10,8 +10,16 @@ import TallyPopupSimple from "@/components/TallyPopup";
import { GoogleAnalytics } from "@next/third-parties/google";
import { Toaster } from "@/components/ui/toaster";
import { IconType } from "@/components/ui/icons";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
const inter = Inter({ subsets: ["latin"] });
// Fonts
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const poppins = Poppins({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-poppins",
});
export const metadata: Metadata = {
title: "NextGen AutoGPT",
@@ -24,7 +32,10 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html
lang="en"
className={`${GeistSans.variable} ${GeistMono.variable} ${poppins.variable} ${inter.variable}`}
>
<body className={cn("antialiased transition-colors", inter.className)}>
<Providers
attribute="class"

View File

@@ -5,11 +5,7 @@ import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import BackendAPI from "@/lib/autogpt-server-api";
const loginFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
});
import { loginFormSchema, LoginProvider } from "@/types/auth";
export async function logout() {
return await Sentry.withServerActionInstrumentation(
@@ -25,7 +21,7 @@ export async function logout() {
const { error } = await supabase.auth.signOut();
if (error) {
console.log("Error logging out", error);
console.error("Error logging out", error);
return error.message;
}
@@ -47,18 +43,13 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signInWithPassword(values);
await api.createUser();
if (error) {
console.log("Error logging in", error);
if (error.status == 400) {
// Hence User is not present
redirect("/login");
}
console.error("Error logging in", error);
return error.message;
}
await api.createUser();
if (data.session) {
await supabase.auth.setSession(data.session);
}
@@ -68,38 +59,34 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
});
}
export async function signup(values: z.infer<typeof loginFormSchema>) {
"use server";
export async function providerLogin(provider: LoginProvider) {
return await Sentry.withServerActionInstrumentation(
"signup",
"providerLogin",
{},
async () => {
const supabase = getServerSupabase();
const api = new BackendAPI();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signUp(values);
const { error } = await supabase!.auth.signInWithOAuth({
provider: provider,
options: {
redirectTo:
process.env.AUTH_CALLBACK_URL ??
`http://localhost:3000/auth/callback`,
},
});
if (error) {
console.log("Error signing up", error);
if (error.message.includes("P0001")) {
return "Please join our waitlist for your turn: https://agpt.co/waitlist";
}
if (error.code?.includes("user_already_exists")) {
redirect("/login");
}
console.error("Error logging in", error);
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
console.log("Signed up");
revalidatePath("/", "layout");
redirect("/store/profile");
await api.createUser();
console.log("Logged in");
},
);
}

View File

@@ -1,10 +1,8 @@
"use client";
import { login, signup } from "./actions";
import { Button } from "@/components/ui/button";
import { login, providerLogin } from "./actions";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -14,40 +12,69 @@ import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { PasswordInput } from "@/components/PasswordInput";
import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa";
import { useState } from "react";
import { useCallback, useState } from "react";
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 { useBackendAPI } from "@/lib/autogpt-server-api/context";
const loginFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
agreeToTerms: z.boolean().refine((value) => value === true, {
message: "You must agree to the Terms of Use and Privacy Policy",
}),
});
import {
AuthCard,
AuthHeader,
AuthButton,
AuthFeedback,
AuthBottomText,
PasswordInput,
} from "@/components/auth";
import { loginFormSchema } from "@/types/auth";
export default function LoginPage() {
const { supabase, user, isUserLoading } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const api = useBackendAPI();
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
email: "",
password: "",
agreeToTerms: false,
},
});
// TODO: uncomment when we enable social login
// const onProviderLogin = useCallback(async (
// provider: LoginProvider,
// ) => {
// setIsLoading(true);
// const error = await providerLogin(provider);
// setIsLoading(false);
// if (error) {
// setFeedback(error);
// return;
// }
// setFeedback(null);
// }, [supabase]);
const onLogin = useCallback(
async (data: z.infer<typeof loginFormSchema>) => {
setIsLoading(true);
if (!(await form.trigger())) {
setIsLoading(false);
return;
}
const error = await login(data);
setIsLoading(false);
if (error) {
setFeedback(error);
return;
}
setFeedback(null);
},
[form],
);
if (user) {
console.debug("User exists, redirecting to /");
router.push("/");
@@ -65,179 +92,60 @@ export default function LoginPage() {
);
}
async function handleSignInWithProvider(
provider: "google" | "github" | "discord",
) {
const { data, error } = await supabase!.auth.signInWithOAuth({
provider: provider,
options: {
redirectTo:
process.env.AUTH_CALLBACK_URL ??
`http://localhost:3000/auth/callback`,
},
});
await api.createUser();
if (!error) {
setFeedback(null);
return;
}
setFeedback(error.message);
}
const onLogin = async (data: z.infer<typeof loginFormSchema>) => {
setIsLoading(true);
const error = await login(data);
setIsLoading(false);
if (error) {
setFeedback(error);
return;
}
setFeedback(null);
};
return (
<div className="flex h-[80vh] items-center justify-center">
<div className="w-full max-w-md space-y-6 rounded-lg p-8 shadow-md">
<h1 className="text-lg font-medium">Log in to your Account </h1>
{/* <div className="mb-6 space-y-2">
<Button
className="w-full"
onClick={() => handleSignInWithProvider("google")}
variant="outline"
type="button"
disabled={isLoading}
<AuthCard>
<AuthHeader>Login to your account</AuthHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onLogin)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel className="flex w-full items-center justify-between">
<span>Password</span>
<Link
href="/reset_password"
className="text-sm font-normal leading-normal text-black underline"
>
Forgot your password?
</Link>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onLogin(form.getValues())}
isLoading={isLoading}
type="submit"
>
<FaGoogle className="mr-2 h-4 w-4" />
Sign in with Google
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("github")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGithub className="mr-2 h-4 w-4" />
Sign in with GitHub
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("discord")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaDiscord className="mr-2 h-4 w-4" />
Sign in with Discord
</Button>
</div> */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onLogin)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="user@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="password" {...field} />
</FormControl>
<FormDescription>
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
I agree to the{" "}
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<div className="mb-6 mt-8 flex w-full space-x-4">
<Button
className="flex w-full justify-center"
type="submit"
disabled={isLoading}
onClick={async () => {
setIsLoading(true);
const values = form.getValues();
const result = await login(values);
if (result) {
setFeedback(result);
}
setIsLoading(false);
}}
>
{isLoading ? <FaSpinner className="animate-spin" /> : "Log in"}
</Button>
<Button
className="flex w-full justify-center"
type="button"
disabled={isLoading}
onClick={async () => {
setIsLoading(true);
const values = form.getValues();
const result = await signup(values);
if (result) {
setFeedback(result);
}
setIsLoading(false);
}}
>
{isLoading ? <FaSpinner className="animate-spin" /> : "Sign up"}
</Button>
</div>
</form>
<p className="text-sm text-red-500">{feedback}</p>
</Form>
<Link href="/reset_password" className="text-sm">
Forgot your password?
</Link>
</div>
</div>
Login
</AuthButton>
</form>
<AuthFeedback message={feedback} isError={true} />
</Form>
<AuthBottomText
text="Don't have an account?"
linkText="Sign up"
href="/signup"
/>
</AuthCard>
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { redirect } from "next/navigation";
export default function Page() {
redirect("/store");
}

View File

@@ -0,0 +1,60 @@
"use server";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { redirect } from "next/navigation";
import * as Sentry from "@sentry/nextjs";
import { headers } from "next/headers";
export async function sendResetEmail(email: string) {
return await Sentry.withServerActionInstrumentation(
"sendResetEmail",
{},
async () => {
const supabase = getServerSupabase();
const headersList = headers();
const host = headersList.get("host");
const protocol =
process.env.NODE_ENV === "development" ? "http" : "https";
const origin = `${protocol}://${host}`;
if (!supabase) {
redirect("/error");
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/reset_password`,
});
if (error) {
console.error("Error sending reset email", error);
return error.message;
}
console.log("Reset email sent");
redirect("/reset_password");
},
);
}
export async function changePassword(password: string) {
return await Sentry.withServerActionInstrumentation(
"changePassword",
{},
async () => {
const supabase = getServerSupabase();
if (!supabase) {
redirect("/error");
}
const { error } = await supabase.auth.updateUser({ password });
if (error) {
console.error("Error changing password", error);
return error.message;
}
await supabase.auth.signOut();
redirect("/login");
},
);
}

View File

@@ -1,8 +1,15 @@
"use client";
import { Button } from "@/components/ui/button";
import {
AuthCard,
AuthHeader,
AuthButton,
AuthFeedback,
PasswordInput,
} from "@/components/auth";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -10,54 +17,87 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import useSupabase from "@/hooks/useSupabase";
import { sendEmailFormSchema, changePasswordFormSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { FaSpinner } from "react-icons/fa";
import { z } from "zod";
const emailFormSchema = z.object({
email: z.string().email().min(2).max(64),
});
const resetPasswordFormSchema = z
.object({
password: z.string().min(6).max(64),
confirmPassword: z.string().min(6).max(64),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
import { changePassword, sendResetEmail } from "./actions";
import Spinner from "@/components/Spinner";
export default function ResetPasswordPage() {
const { supabase, user, isUserLoading } = useSupabase();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const [disabled, setDisabled] = useState(false);
const emailForm = useForm<z.infer<typeof emailFormSchema>>({
resolver: zodResolver(emailFormSchema),
const sendEmailForm = useForm<z.infer<typeof sendEmailFormSchema>>({
resolver: zodResolver(sendEmailFormSchema),
defaultValues: {
email: "",
},
});
const resetPasswordForm = useForm<z.infer<typeof resetPasswordFormSchema>>({
resolver: zodResolver(resetPasswordFormSchema),
const changePasswordForm = useForm<z.infer<typeof changePasswordFormSchema>>({
resolver: zodResolver(changePasswordFormSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
});
const onSendEmail = useCallback(
async (data: z.infer<typeof sendEmailFormSchema>) => {
setIsLoading(true);
setFeedback(null);
if (!(await sendEmailForm.trigger())) {
setIsLoading(false);
return;
}
const error = await sendResetEmail(data.email);
setIsLoading(false);
if (error) {
setFeedback(error);
setIsError(true);
return;
}
setDisabled(true);
setFeedback(
"Password reset email sent if user exists. Please check your email.",
);
setIsError(false);
},
[sendEmailForm],
);
const onChangePassword = useCallback(
async (data: z.infer<typeof changePasswordFormSchema>) => {
setIsLoading(true);
setFeedback(null);
if (!(await changePasswordForm.trigger())) {
setIsLoading(false);
return;
}
const error = await changePassword(data.password);
setIsLoading(false);
if (error) {
setFeedback(error);
setIsError(true);
return;
}
setFeedback("Password changed successfully. Redirecting to login.");
setIsError(false);
},
[changePasswordForm],
);
if (isUserLoading) {
return (
<div className="flex h-[80vh] items-center justify-center">
<FaSpinner className="mr-2 h-16 w-16 animate-spin" />
</div>
);
return <Spinner />;
}
if (!supabase) {
@@ -68,147 +108,79 @@ export default function ResetPasswordPage() {
);
}
async function onSendEmail(d: z.infer<typeof emailFormSchema>) {
setIsLoading(true);
setFeedback(null);
if (!(await emailForm.trigger())) {
setIsLoading(false);
return;
}
const { data, error } = await supabase!.auth.resetPasswordForEmail(
d.email,
{
redirectTo: `${window.location.origin}/reset_password`,
},
);
if (error) {
setFeedback(error.message);
setIsLoading(false);
return;
}
setFeedback("Password reset email sent. Please check your email.");
setIsLoading(false);
}
async function onResetPassword(d: z.infer<typeof resetPasswordFormSchema>) {
setIsLoading(true);
setFeedback(null);
if (!(await resetPasswordForm.trigger())) {
setIsLoading(false);
return;
}
const { data, error } = await supabase!.auth.updateUser({
password: d.password,
});
if (error) {
setFeedback(error.message);
setIsLoading(false);
return;
}
await supabase!.auth.signOut();
router.push("/login");
}
return (
<div className="flex h-full flex-col items-center justify-center">
<div className="w-full max-w-md">
<h1 className="text-center text-3xl font-bold">Reset Password</h1>
{user ? (
<form
onSubmit={resetPasswordForm.handleSubmit(onResetPassword)}
className="mt-6 space-y-6"
>
<Form {...resetPasswordForm}>
<FormField
control={resetPasswordForm.control}
name="password"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={resetPasswordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading}
onClick={() => onResetPassword(resetPasswordForm.getValues())}
>
{isLoading ? <FaSpinner className="mr-2 animate-spin" /> : null}
Reset Password
</Button>
</Form>
</form>
) : (
<form
onSubmit={emailForm.handleSubmit(onSendEmail)}
className="mt-6 space-y-6"
>
<Form {...emailForm}>
<FormField
control={emailForm.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="user@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading}
onClick={() => onSendEmail(emailForm.getValues())}
>
{isLoading ? <FaSpinner className="mr-2 animate-spin" /> : null}
Send Reset Email
</Button>
{feedback ? (
<div className="text-center text-sm text-red-500">
{feedback}
</div>
) : null}
</Form>
</form>
)}
</div>
</div>
<AuthCard>
<AuthHeader>Reset Password</AuthHeader>
{user ? (
<form onSubmit={changePasswordForm.handleSubmit(onChangePassword)}>
<Form {...changePasswordForm}>
<FormField
control={changePasswordForm.control}
name="password"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={changePasswordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onChangePassword(changePasswordForm.getValues())}
isLoading={isLoading}
type="submit"
>
Update password
</AuthButton>
<AuthFeedback message={feedback} isError={isError} />
</Form>
</form>
) : (
<form onSubmit={sendEmailForm.handleSubmit(onSendEmail)}>
<Form {...sendEmailForm}>
<FormField
control={sendEmailForm.control}
name="email"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onSendEmail(sendEmailForm.getValues())}
isLoading={isLoading}
disabled={disabled}
type="submit"
>
Send reset email
</AuthButton>
<AuthFeedback message={feedback} isError={isError} />
</Form>
</form>
)}
</AuthCard>
);
}

View File

@@ -0,0 +1,44 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { signupFormSchema } from "@/types/auth";
export async function signup(values: z.infer<typeof signupFormSchema>) {
"use server";
return await Sentry.withServerActionInstrumentation(
"signup",
{},
async () => {
const supabase = getServerSupabase();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signUp(values);
if (error) {
console.error("Error signing up", error);
// FIXME: supabase doesn't return the correct error message for this case
if (error.message.includes("P0001")) {
return "Please join our waitlist for your turn: https://agpt.co/waitlist";
}
if (error.code?.includes("user_already_exists")) {
redirect("/login");
}
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
console.log("Signed up");
revalidatePath("/", "layout");
redirect("/store/profile");
},
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import { signup } from "./actions";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useState } from "react";
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 {
AuthCard,
AuthHeader,
AuthButton,
AuthFeedback,
AuthBottomText,
PasswordInput,
} from "@/components/auth";
import { signupFormSchema } from "@/types/auth";
export default function SignupPage() {
const { supabase, user, isUserLoading } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [showWaitlistPrompt, setShowWaitlistPrompt] = useState(false);
const form = useForm<z.infer<typeof signupFormSchema>>({
resolver: zodResolver(signupFormSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: "",
agreeToTerms: false,
},
});
const onSignup = useCallback(
async (data: z.infer<typeof signupFormSchema>) => {
setIsLoading(true);
if (!(await form.trigger())) {
setIsLoading(false);
return;
}
const error = await signup(data);
setIsLoading(false);
if (error) {
setShowWaitlistPrompt(true);
return;
}
setFeedback(null);
},
[form],
);
if (user) {
console.debug("User exists, redirecting to /");
router.push("/");
}
if (isUserLoading || user) {
return <Spinner />;
}
if (!supabase) {
return (
<div>
User accounts are disabled because Supabase client is unavailable
</div>
);
}
return (
<AuthCard>
<AuthHeader>Create a new account</AuthHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSignup)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onSignup(form.getValues())}
isLoading={isLoading}
type="submit"
>
Sign up
</AuthButton>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-6 flex flex-row items-start -space-y-1 space-x-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="">
<FormLabel>
<span className="mr-1 text-sm font-normal leading-normal text-slate-950">
I agree to the
</span>
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
Terms of Use
</Link>
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
and
</span>
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</form>
<AuthFeedback message={feedback} isError={true} />
</Form>
{showWaitlistPrompt && (
<div>
<span className="mr-1 text-sm font-normal leading-normal text-red-500">
The provided email may not be allowed to sign up.
</span>
<br />
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
- AutoGPT Platform is currently in closed beta. You can join
</span>
<Link
href="https://agpt.co/waitlist"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
the waitlist here.
</Link>
<br />
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
- Make sure you use the same email address you used to sign up for
the waitlist.
</span>
<br />
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
- You can self host the platform, visit our
</span>
<Link
href="https://agpt.co/waitlist"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
GitHub repository.
</Link>
</div>
)}
<AuthBottomText
text="Already a member?"
linkText="Log in"
href="/login"
/>
</AuthCard>
);
}

View File

@@ -0,0 +1,11 @@
import { APIKeysSection } from "@/components/agptui/composite/APIKeySection";
const ApiKeysPage = () => {
return (
<div className="w-full pr-4 pt-24 md:pt-0">
<APIKeysSection />
</div>
);
};
export default ApiKeysPage;

View File

@@ -8,6 +8,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
{ text: "Creator Dashboard", href: "/store/dashboard" },
{ text: "Agent dashboard", href: "/store/agent-dashboard" },
{ text: "Integrations", href: "/store/integrations" },
{ text: "API Keys", href: "/store/api_keys" },
{ text: "Profile", href: "/store/profile" },
{ text: "Settings", href: "/store/settings" },
],
@@ -17,7 +18,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen w-screen max-w-[1360px] flex-col lg:flex-row">
<Sidebar linkGroups={sidebarLinkGroups} />
<div className="pl-4">{children}</div>
<div className="flex-1 pl-4">{children}</div>
</div>
);
}

View File

@@ -40,7 +40,8 @@ export default async function Page({
const agent = await api.getStoreAgent(creator_lower, params.slug);
const otherAgents = await api.getStoreAgents({ creator: creator_lower });
const similarAgents = await api.getStoreAgents({
search_query: agent.categories[0],
// We are using slug as we know its has been sanitized and is not null
search_query: agent.slug.replace(/-/g, " "),
});
const breadcrumbs = [

View File

@@ -245,6 +245,7 @@ export function CustomNode({
].includes(nodeType) &&
// No input connection handles for credentials
propKey !== "credentials" &&
!propKey.endsWith("_credentials") &&
// For OUTPUT blocks, only show the 'value' (hides 'name') input connection handle
!(nodeType == BlockUIType.OUTPUT && propKey == "name");
const isConnected = isInputHandleConnected(propKey);
@@ -261,7 +262,8 @@ export function CustomNode({
side="left"
/>
) : (
propKey != "credentials" && (
propKey !== "credentials" &&
!propKey.endsWith("_credentials") && (
<div className="flex gap-1">
<span className="text-m green mb-0 text-gray-900 dark:text-gray-100">
{propSchema.title || beautifyString(propKey)}
@@ -726,13 +728,10 @@ export function CustomNode({
</div>
{/* Body */}
<div className="ml-5 mt-6 rounded-b-xl">
<div className="mx-5 my-6 rounded-b-xl">
{/* Input Handles */}
{data.uiType !== BlockUIType.NOTE ? (
<div
className="flex w-fit items-start justify-between"
data-id="input-handles"
>
<div data-id="input-handles">
<div>
{data.uiType === BlockUIType.WEBHOOK_MANUAL &&
(data.webhook ? (
@@ -781,7 +780,6 @@ export function CustomNode({
<Switch
onCheckedChange={toggleAdvancedSettings}
checked={isAdvancedOpen}
className="mr-5"
/>
</div>
</>
@@ -790,7 +788,7 @@ export function CustomNode({
{data.uiType !== BlockUIType.NOTE && (
<>
<LineSeparator />
<div className="flex items-start justify-end rounded-b-xl pb-2 pr-2 pt-6">
<div className="flex items-start justify-end rounded-b-xl pt-6">
<div className="flex-none">
{data.outputSchema &&
generateOutputHandles(data.outputSchema, data.uiType)}
@@ -850,8 +848,10 @@ export function CustomNode({
data.status === "COMPLETED",
"border-yellow-600 bg-yellow-600 text-white":
data.status === "RUNNING",
"border-red-600 bg-red-600 text-white":
data.status === "FAILED",
"border-red-600 bg-red-600 text-white": [
"FAILED",
"TERMINATED",
].includes(data.status || ""),
"border-blue-600 bg-blue-600 text-white":
data.status === "QUEUED",
"border-gray-600 bg-gray-600 font-black":

View File

@@ -82,7 +82,7 @@ const NodeHandle: FC<HandleProps> = ({
data-testid={`output-handle-${keyName}`}
position={Position.Right}
id={keyName}
className="group -mr-[26px]"
className="group -mr-[38px]"
>
<div className="pointer-events-none flex items-center">
{label}

View File

@@ -61,7 +61,7 @@ const TallyPopupSimple = () => {
<Button
variant="default"
onClick={resetTutorial}
className="font-inter mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left text-lg font-medium leading-6"
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"
>
Tutorial
</Button>

View File

@@ -6,6 +6,10 @@ import { Separator } from "@/components/ui/separator";
import BackendAPI from "@/lib/autogpt-server-api";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useToast } from "@/components/ui/use-toast";
import useSupabase from "@/hooks/useSupabase";
import { DownloadIcon, LoaderIcon } from "lucide-react";
interface AgentInfoProps {
name: string;
creator: string;
@@ -32,8 +36,11 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
storeListingVersionId,
}) => {
const router = useRouter();
const api = React.useMemo(() => new BackendAPI(), []);
const { user } = useSupabase();
const { toast } = useToast();
const [downloading, setDownloading] = React.useState(false);
const handleAddToLibrary = async () => {
try {
@@ -45,6 +52,46 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
}
};
const handleDownloadToLibrary = async () => {
const downloadAgent = async (): Promise<void> => {
setDownloading(true);
try {
const file = await api.downloadStoreAgent(storeListingVersionId);
// Similar to Marketplace v1
const jsonData = JSON.stringify(file, null, 2);
// Create a Blob from the file content
const blob = new Blob([jsonData], { type: "application/json" });
// Create a temporary URL for the Blob
const url = window.URL.createObjectURL(blob);
// Create a temporary anchor element
const a = document.createElement("a");
a.href = url;
a.download = `agent_${storeListingVersionId}.json`; // Set the filename
// Append the anchor to the body, click it, and remove it
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke the temporary URL
window.URL.revokeObjectURL(url);
toast({
title: "Download Complete",
description: "Your agent has been successfully downloaded.",
});
} catch (error) {
console.error(`Error downloading agent:`, error);
throw error;
}
};
await downloadAgent();
setDownloading(false);
};
return (
<div className="w-full max-w-[396px] px-4 sm:px-6 lg:w-[396px] lg:px-0">
{/* Title */}
@@ -72,15 +119,36 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
{/* Run Agent Button */}
<div className="mb-4 w-full lg:mb-[60px]">
<button
onClick={handleAddToLibrary}
className="inline-flex w-full items-center justify-center gap-2 rounded-[38px] bg-violet-600 px-4 py-3 transition-colors hover:bg-violet-700 sm:w-auto sm:gap-2.5 sm:px-5 sm:py-3.5 lg:px-6 lg:py-4"
>
<IconPlay className="h-5 w-5 text-white sm:h-5 sm:w-5 lg:h-6 lg:w-6" />
<span className="font-poppins text-base font-medium text-neutral-50 sm:text-lg">
Add To Library
</span>
</button>
{user ? (
<button
onClick={handleAddToLibrary}
className="inline-flex w-full items-center justify-center gap-2 rounded-[38px] bg-violet-600 px-4 py-3 transition-colors hover:bg-violet-700 sm:w-auto sm:gap-2.5 sm:px-5 sm:py-3.5 lg:px-6 lg:py-4"
>
<IconPlay className="h-5 w-5 text-white sm:h-5 sm:w-5 lg:h-6 lg:w-6" />
<span className="font-poppins text-base font-medium text-neutral-50 sm:text-lg">
Add To Library
</span>
</button>
) : (
<button
onClick={handleDownloadToLibrary}
className={`inline-flex w-full items-center justify-center gap-2 rounded-[38px] px-4 py-3 transition-colors sm:w-auto sm:gap-2.5 sm:px-5 sm:py-3.5 lg:px-6 lg:py-4 ${
downloading
? "bg-neutral-400"
: "bg-violet-600 hover:bg-violet-700"
}`}
disabled={downloading}
>
{downloading ? (
<LoaderIcon className="h-5 w-5 animate-spin text-white sm:h-5 sm:w-5 lg:h-6 lg:w-6" />
) : (
<DownloadIcon className="h-5 w-5 text-white sm:h-5 sm:w-5 lg:h-6 lg:w-6" />
)}
<span className="font-poppins text-base font-medium text-neutral-50 sm:text-lg">
{downloading ? "Downloading..." : "Download Agent as File"}
</span>
</button>
)}
</div>
{/* Rating and Runs */}

View File

@@ -2,7 +2,7 @@ import * as React from "react";
import Link from "next/link";
import { Button } from "./Button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Menu } from "lucide-react";
import { KeyIcon, Menu } from "lucide-react";
import {
IconDashboardLayout,
IconIntegrations,
@@ -58,6 +58,15 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
Integrations
</div>
</Link>
<Link
href="/store/api_keys"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
>
<KeyIcon className="h-6 w-6" />
<div className="p-ui-medium text-base font-medium leading-normal">
API Keys
</div>
</Link>
<Link
href="/store/profile"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
@@ -102,6 +111,15 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
Integrations
</div>
</Link>
<Link
href="/store/api_keys"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
>
<KeyIcon className="h-6 w-6" strokeWidth={1} />
<div className="p-ui-medium text-base font-medium leading-normal">
API Keys
</div>
</Link>
<Link
href="/store/profile"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"

View File

@@ -0,0 +1,296 @@
"use client";
import { useState, useEffect } from "react";
import { APIKey, APIKeyPermission } from "@/lib/autogpt-server-api/types";
import { LuCopy } from "react-icons/lu";
import { Loader2, MoreVertical } from "lucide-react";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useToast } from "@/components/ui/use-toast";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function APIKeysSection() {
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isKeyDialogOpen, setIsKeyDialogOpen] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyDescription, setNewKeyDescription] = useState("");
const [newApiKey, setNewApiKey] = useState("");
const [selectedPermissions, setSelectedPermissions] = useState<
APIKeyPermission[]
>([]);
const { toast } = useToast();
const api = useBackendAPI();
useEffect(() => {
loadAPIKeys();
}, []);
const loadAPIKeys = async () => {
setIsLoading(true);
try {
const keys = await api.listAPIKeys();
setApiKeys(keys.filter((key) => key.status === "ACTIVE"));
} finally {
setIsLoading(false);
}
};
const handleCreateKey = async () => {
try {
const response = await api.createAPIKey(
newKeyName,
selectedPermissions,
newKeyDescription,
);
setNewApiKey(response.plain_text_key);
setIsCreateOpen(false);
setIsKeyDialogOpen(true);
loadAPIKeys();
} catch (error) {
toast({
title: "Error",
description: "Failed to create AutoGPT Platform API key",
variant: "destructive",
});
}
};
const handleCopyKey = () => {
navigator.clipboard.writeText(newApiKey);
toast({
title: "Copied",
description: "AutoGPT Platform API key copied to clipboard",
});
};
const handleRevokeKey = async (keyId: string) => {
try {
await api.revokeAPIKey(keyId);
toast({
title: "Success",
description: "AutoGPT Platform API key revoked successfully",
});
loadAPIKeys();
} catch (error) {
toast({
title: "Error",
description: "Failed to revoke AutoGPT Platform API key",
variant: "destructive",
});
}
};
return (
<Card>
<CardHeader>
<CardTitle>AutoGPT Platform API Keys</CardTitle>
<CardDescription>
Manage your AutoGPT Platform API keys for programmatic access
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 flex justify-end">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button>Create Key</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New API Key</DialogTitle>
<DialogDescription>
Create a new AutoGPT Platform API key
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="My AutoGPT Platform API Key"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description (Optional)</Label>
<Input
id="description"
value={newKeyDescription}
onChange={(e) => setNewKeyDescription(e.target.value)}
placeholder="Used for..."
/>
</div>
<div className="grid gap-2">
<Label>Permissions</Label>
{Object.values(APIKeyPermission).map((permission) => (
<div
className="flex items-center space-x-2"
key={permission}
>
<Checkbox
id={permission}
checked={selectedPermissions.includes(permission)}
onCheckedChange={(checked) => {
setSelectedPermissions(
checked
? [...selectedPermissions, permission]
: selectedPermissions.filter(
(p) => p !== permission,
),
);
}}
/>
<Label htmlFor={permission}>{permission}</Label>
</div>
))}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button onClick={handleCreateKey}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isKeyDialogOpen} onOpenChange={setIsKeyDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>AutoGPT Platform API Key Created</DialogTitle>
<DialogDescription>
Please copy your AutoGPT API key now. You won&apos;t be able
to see it again!
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2">
<code className="flex-1 rounded-md bg-secondary p-2 text-sm">
{newApiKey}
</code>
<Button size="icon" variant="outline" onClick={handleCopyKey}>
<LuCopy className="h-4 w-4" />
</Button>
</div>
<DialogFooter>
<Button onClick={() => setIsKeyDialogOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
apiKeys.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>API Key</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Used</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
<TableCell>{key.name}</TableCell>
<TableCell>
<div className="rounded-md border p-1 px-2 text-xs">
{`${key.prefix}******************${key.postfix}`}
</div>
</TableCell>
<TableCell>
<Badge
variant={
key.status === "ACTIVE" ? "default" : "destructive"
}
className={
key.status === "ACTIVE"
? "border-green-600 bg-green-100 text-green-800"
: "border-red-600 bg-red-100 text-red-800"
}
>
{key.status}
</Badge>
</TableCell>
<TableCell>
{new Date(key.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
{key.last_used_at
? new Date(key.last_used_at).toLocaleDateString()
: "Never"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={() => handleRevokeKey(key.id)}
>
Revoke
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
)}
</CardContent>
</Card>
);
}

View File

@@ -34,8 +34,8 @@ export const AgentsSection: React.FC<AgentsSectionProps> = ({
}) => {
const router = useRouter();
// Take only the first 9 agents
const displayedAgents = allAgents.slice(0, 9);
// TODO: Update this when we have pagination
const displayedAgents = allAgents;
const handleCardClick = (creator: string, slug: string) => {
router.push(

View File

@@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
import Link from "next/link";
interface Props {
className?: string;
text: string;
linkText?: string;
href?: string;
}
export default function AuthBottomText({
className = "",
text,
linkText,
href = "",
}: Props) {
return (
<div
className={cn(
className,
"mt-8 inline-flex w-full items-center justify-center",
)}
>
<span className="text-sm font-medium leading-normal text-slate-950">
{text}
</span>
{linkText && (
<Link
href={href}
className="ml-1 text-sm font-medium leading-normal text-slate-950 underline"
>
{linkText}
</Link>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { ReactNode } from "react";
import { Button } from "../ui/button";
import { FaSpinner } from "react-icons/fa";
interface Props {
children?: ReactNode;
onClick: () => void;
isLoading?: boolean;
disabled?: boolean;
type?: "button" | "submit" | "reset";
}
export default function AuthButton({
children,
onClick,
isLoading = false,
disabled = false,
type = "button",
}: Props) {
return (
<Button
className="mt-2 w-full self-stretch rounded-md bg-slate-900 px-4 py-2"
type={type}
disabled={isLoading || disabled}
onClick={onClick}
>
{isLoading ? (
<FaSpinner className="animate-spin" />
) : (
<div className="text-sm font-medium leading-normal text-slate-50">
{children}
</div>
)}
</Button>
);
}

View File

@@ -0,0 +1,15 @@
import { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export default function AuthCard({ children }: Props) {
return (
<div className="flex h-[80vh] w-[32rem] items-center justify-center">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-md">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
interface Props {
message?: string | null;
isError?: boolean;
}
export default function AuthFeedback({ message = "", isError = false }: Props) {
return (
<div className="mt-4 text-center text-sm font-medium leading-normal">
{isError ? (
<div className="text-red-500">{message}</div>
) : (
<div className="text-slate-950">{message}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export default function AuthHeader({ children }: Props) {
return (
<div className="mb-8 text-2xl font-semibold leading-normal text-slate-950">
{children}
</div>
);
}

View File

@@ -16,6 +16,7 @@ const PasswordInput = forwardRef<HTMLInputElement, InputProps>(
type={showPassword ? "text" : "password"}
className={cn("hide-password-toggle pr-10", className)}
ref={ref}
title="password"
{...props}
/>
<Button
@@ -23,8 +24,11 @@ const PasswordInput = forwardRef<HTMLInputElement, InputProps>(
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
onMouseDown={() => setShowPassword(true)}
onMouseUp={() => setShowPassword(false)}
onMouseLeave={() => setShowPassword(false)}
disabled={disabled}
tabIndex={-1}
>
{showPassword && !disabled ? (
<EyeIcon className="h-4 w-4" aria-hidden="true" />

View File

@@ -0,0 +1,15 @@
import AuthBottomText from "./AuthBottomText";
import AuthButton from "./AuthButton";
import AuthCard from "./AuthCard";
import AuthFeedback from "./AuthFeedback";
import AuthHeader from "./AuthHeader";
import { PasswordInput } from "./PasswordInput";
export {
AuthBottomText,
AuthButton,
AuthCard,
AuthFeedback,
AuthHeader,
PasswordInput,
};

View File

@@ -4,58 +4,22 @@
transition: border-color 0.3s ease-in-out;
}
.custom-node [data-id="input-handles"] {
padding: 0 1.25rem;
margin-bottom: 1rem;
}
.custom-node [data-id="input-handles"],
.custom-node [data-id="input-handles"] > div > div {
margin-bottom: 1rem;
}
.handle-container {
display: flex;
position: relative;
margin-bottom: 0px;
padding: 0.75rem 1.25rem;
min-height: 44px;
height: 100%;
}
.custom-node input:not([type="checkbox"]),
.custom-node textarea,
.custom-node select {
width: calc(100% - 2.5rem);
max-width: 400px;
margin: 0.5rem 1.25rem;
}
.custom-node [data-id^="date-picker"] {
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
}
.custom-node [data-list-container] {
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
}
.custom-node [data-add-item] {
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
padding: 0.5rem;
}
.array-item-container {
.custom-node select,
.custom-node [data-id^="date-picker"],
.custom-node [data-list-container],
.custom-node [data-add-item],
.custom-node [data-content-settings]. .array-item-container {
display: flex;
align-items: center;
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
}
.custom-node [data-content-settings] {
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
min-width: calc(100% - 2.5rem);
max-width: 100%;
}
.custom-node .custom-switch {
@@ -68,7 +32,6 @@
.error-message {
color: #d9534f;
font-size: 13px;
margin: 0.25rem 1.25rem;
padding-left: 0.5rem;
}

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { cn } from "@/lib/utils";
import { beautifyString, cn } from "@/lib/utils";
import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -53,6 +53,7 @@ export const providerIcons: Record<
google: FaGoogle,
groq: fallbackIcon,
notion: NotionLogoIcon,
nvidia: fallbackIcon,
discord: FaDiscord,
d_id: fallbackIcon,
google_maps: FaGoogle,
@@ -87,12 +88,13 @@ export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
);
export const CredentialsInput: FC<{
selfKey: string;
className?: string;
selectedCredentials?: CredentialsMetaInput;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
}> = ({ className, selectedCredentials, onSelectCredentials }) => {
}> = ({ selfKey, className, selectedCredentials, onSelectCredentials }) => {
const api = useBackendAPI();
const credentials = useCredentials();
const credentials = useCredentials(selfKey);
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
@@ -209,6 +211,7 @@ export const CredentialsInput: FC<{
<>
{supportsApiKey && (
<APIKeyCredentialsModal
credentialsFieldName={selfKey}
open={isAPICredentialsModalOpen}
onClose={() => setAPICredentialsModalOpen(false)}
onCredentialsCreate={(credsMeta) => {
@@ -242,7 +245,9 @@ export const CredentialsInput: FC<{
return (
<>
<div className="mb-2 flex gap-1">
<span className="text-m green text-gray-900">Credentials</span>
<span className="text-m green text-gray-900">
{providerName} Credentials
</span>
<SchemaTooltip description={schema.description} />
</div>
<div className={cn("flex flex-row space-x-2", className)}>
@@ -310,7 +315,12 @@ export const CredentialsInput: FC<{
// Saved credentials exist
return (
<>
<span className="text-m green mb-0 text-gray-900">Credentials</span>
<div className="flex gap-1">
<span className="text-m green mb-0 text-gray-900">
{providerName} Credentials
</span>
<SchemaTooltip description={schema.description} />
</div>
<Select value={selectedCredentials?.id} onValueChange={handleValueChange}>
<SelectTrigger>
<SelectValue placeholder={schema.placeholder} />
@@ -353,11 +363,12 @@ export const CredentialsInput: FC<{
};
export const APIKeyCredentialsModal: FC<{
credentialsFieldName: string;
open: boolean;
onClose: () => void;
onCredentialsCreate: (creds: CredentialsMetaInput) => void;
}> = ({ open, onClose, onCredentialsCreate }) => {
const credentials = useCredentials();
}> = ({ credentialsFieldName, open, onClose, onCredentialsCreate }) => {
const credentials = useCredentials(credentialsFieldName);
const formSchema = z.object({
apiKey: z.string().min(1, "API Key is required"),

View File

@@ -28,6 +28,7 @@ const providerDisplayNames: Record<CredentialsProviderName, string> = {
jina: "Jina",
medium: "Medium",
notion: "Notion",
nvidia: "Nvidia",
ollama: "Ollama",
openai: "OpenAI",
openweathermap: "OpenWeatherMap",

View File

@@ -101,6 +101,92 @@ const NodeObjectInputTree: FC<NodeObjectInputTreeProps> = ({
export default NodeObjectInputTree;
const NodeImageInput: FC<{
selfKey: string;
schema: BlockIOStringSubSchema;
value?: string;
error?: string;
handleInputChange: NodeObjectInputTreeProps["handleInputChange"];
className?: string;
displayName: string;
}> = ({
selfKey,
schema,
value = "",
error,
handleInputChange,
className,
displayName,
}) => {
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith("image/")) {
console.error("Please upload an image file");
return;
}
// Convert to base64
const reader = new FileReader();
reader.onload = (e) => {
const base64String = (e.target?.result as string).split(",")[1];
handleInputChange(selfKey, base64String);
};
reader.readAsDataURL(file);
},
[selfKey, handleInputChange],
);
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="nodrag flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() =>
document.getElementById(`${selfKey}-upload`)?.click()
}
className="w-full"
>
{value ? "Change Image" : `Upload ${displayName}`}
</Button>
{value && (
<Button
variant="ghost"
className="text-red-500 hover:text-red-700"
onClick={() => handleInputChange(selfKey, "")}
>
<Cross2Icon className="h-4 w-4" />
</Button>
)}
</div>
<input
id={`${selfKey}-upload`}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
{value && (
<div className="relative mt-2 rounded-md border border-gray-300 p-2 dark:border-gray-600">
<img
src={`data:image/jpeg;base64,${value}`}
alt="Preview"
className="max-h-32 w-full rounded-md object-contain"
/>
</div>
)}
</div>
{error && <span className="error-message">{error}</span>}
</div>
);
};
const NodeDateTimeInput: FC<{
selfKey: string;
schema: BlockIOStringSubSchema;
@@ -201,7 +287,7 @@ export const NodeGenericInputField: FC<{
className,
displayName,
}) => {
className = cn(className, "my-2");
className = cn(className);
displayName ||= propSchema.title || beautifyString(propKey);
if ("allOf" in propSchema) {
@@ -418,6 +504,19 @@ export const NodeGenericInputField: FC<{
switch (propSchema.type) {
case "string":
if ("image_upload" in propSchema && propSchema.image_upload === true) {
return (
<NodeImageInput
selfKey={propKey}
schema={propSchema}
value={currentValue}
error={errors[propKey]}
className={className}
displayName={displayName}
handleInputChange={handleInputChange}
/>
);
}
if ("format" in propSchema && propSchema.format === "date-time") {
return (
<NodeDateTimeInput
@@ -653,6 +752,7 @@ const NodeCredentialsInput: FC<{
return (
<div className={cn("flex flex-col", className)}>
<CredentialsInput
selfKey={selfKey}
onSelectCredentials={(credsMeta) =>
handleInputChange(selfKey, credsMeta)
}
@@ -876,18 +976,19 @@ const NodeArrayInput: FC<{
(c) => c.targetHandle === entryKey && c.target === nodeId,
);
return (
<div key={entryKey} className="self-start">
<div key={entryKey}>
<NodeHandle
keyName={entryKey}
schema={schema.items!}
isConnected={isConnected}
isRequired={false}
side="left"
/>
<div className="mb-2 flex space-x-2">
<NodeHandle
keyName={entryKey}
schema={schema.items!}
isConnected={isConnected}
isRequired={false}
side="left"
/>
{!isConnected &&
(schema.items ? (
<NodeGenericInputField
className="w-full"
nodeId={nodeId}
propKey={entryKey}
propSchema={schema.items}

View File

@@ -17,6 +17,9 @@ const isValidVideoUrl = (url: string): boolean => {
};
const isValidImageUrl = (url: string): boolean => {
if (url.startsWith("data:image/")) {
return true;
}
const imageExtensions = /\.(jpeg|jpg|gif|png|svg|webp)$/i;
const cleanedUrl = url.split("?")[0];
return imageExtensions.test(cleanedUrl);
@@ -50,19 +53,21 @@ const VideoRenderer: React.FC<{ videoUrl: string }> = ({ videoUrl }) => {
);
};
const ImageRenderer: React.FC<{ imageUrl: string }> = ({ imageUrl }) => (
<div className="w-full p-2">
<picture>
<img
src={imageUrl}
alt="Image"
className="h-auto max-w-full"
width="100%"
height="auto"
/>
</picture>
</div>
);
const ImageRenderer: React.FC<{ imageUrl: string }> = ({ imageUrl }) => {
return (
<div className="w-full p-2">
<picture>
<img
src={imageUrl}
alt="Image"
className="h-auto max-w-full"
width="100%"
height="auto"
/>
</picture>
</div>
);
};
const AudioRenderer: React.FC<{ audioUrl: string }> = ({ audioUrl }) => (
<div className="w-full p-2">
@@ -92,6 +97,9 @@ export const ContentRenderer: React.FC<{
truncateLongData?: boolean;
}> = ({ value, truncateLongData }) => {
if (typeof value === "string") {
if (value.startsWith("data:image/")) {
return <ImageRenderer imageUrl={value} />;
}
if (isValidVideoUrl(value)) {
return <VideoRenderer videoUrl={value} />;
} else if (isValidImageUrl(value)) {

View File

@@ -558,8 +558,9 @@ export default function useAgentGraph(
return;
}
if (
nodeResult.status != "COMPLETED" &&
nodeResult.status != "FAILED"
!["COMPLETED", "TERMINATED", "FAILED"].includes(
nodeResult.status,
)
) {
pendingNodeExecutions.add(nodeResult.node_exec_id);
} else {

View File

@@ -26,7 +26,9 @@ export type CredentialsData =
isLoading: false;
});
export default function useCredentials(): CredentialsData | null {
export default function useCredentials(
inputFieldName: string,
): CredentialsData | null {
const nodeId = useNodeId();
const allProviders = useContext(CredentialsProvidersContext);
@@ -35,8 +37,9 @@ export default function useCredentials(): CredentialsData | null {
}
const data = useNodesData<Node<CustomNodeData>>(nodeId)!.data;
const credentialsSchema = data.inputSchema.properties
.credentials as BlockIOCredentialsSubSchema;
const credentialsSchema = data.inputSchema.properties[
inputFieldName
] as BlockIOCredentialsSubSchema;
const discriminatorValue: CredentialsProviderName | null =
(credentialsSchema.discriminator &&

View File

@@ -28,7 +28,11 @@ export default function useSupabase() {
const response = await supabase.auth.getUser();
if (response.error) {
console.error("Error fetching user", response.error);
// Display error only if it's not about missing auth session (user is not logged in)
if (response.error.message !== "Auth session missing!") {
console.error("Error fetching user", response.error);
}
setUser(null);
} else {
setUser(response.data.user);
}

View File

@@ -29,6 +29,9 @@ import {
StoreReview,
ScheduleCreatable,
Schedule,
APIKeyPermission,
CreateAPIKeyResponse,
APIKey,
} from "./types";
import { createBrowserClient } from "@supabase/ssr";
import getServerSupabase from "../supabase/getServerSupabase";
@@ -221,6 +224,36 @@ export default class BackendAPI {
);
}
// API Key related requests
async createAPIKey(
name: string,
permissions: APIKeyPermission[],
description?: string,
): Promise<CreateAPIKeyResponse> {
return this._request("POST", "/api-keys", {
name,
permissions,
description,
});
}
async listAPIKeys(): Promise<APIKey[]> {
return this._get("/api-keys");
}
async revokeAPIKey(keyId: string): Promise<APIKey> {
return this._request("DELETE", `/api-keys/${keyId}`);
}
async updateAPIKeyPermissions(
keyId: string,
permissions: APIKeyPermission[],
): Promise<APIKey> {
return this._request("PUT", `/api-keys/${keyId}/permissions`, {
permissions,
});
}
/**
* @returns `true` if a ping event was received, `false` if provider doesn't support pinging but the webhook exists.
* @throws `Error` if the webhook does not exist.
@@ -311,7 +344,7 @@ export default class BackendAPI {
"/store/submissions/generate_image?agent_id=" + agent_id,
);
}
c;
deleteStoreSubmission(submission_id: string): Promise<boolean> {
return this._request("DELETE", `/store/submissions/${submission_id}`);
}
@@ -348,6 +381,17 @@ export default class BackendAPI {
return this._get("/store/myagents", params);
}
downloadStoreAgent(
storeListingVersionId: string,
version?: number,
): Promise<BlobPart> {
const url = version
? `/store/download/agents/${storeListingVersionId}?version=${version}`
: `/store/download/agents/${storeListingVersionId}`;
return this._get(url);
}
/////////////////////////////////////////
/////////// V2 LIBRARY API //////////////
/////////////////////////////////////////

View File

@@ -113,6 +113,7 @@ export const PROVIDER_NAMES = {
JINA: "jina",
MEDIUM: "medium",
NOTION: "notion",
NVIDIA: "nvidia",
OLLAMA: "ollama",
OPENAI: "openai",
OPENWEATHERMAP: "openweathermap",
@@ -196,7 +197,7 @@ export type GraphExecution = {
ended_at: number;
duration: number;
total_run_time: number;
status: "INCOMPLETE" | "QUEUED" | "RUNNING" | "COMPLETED" | "FAILED";
status: "QUEUED" | "RUNNING" | "COMPLETED" | "TERMINATED" | "FAILED";
graph_id: string;
graph_version: number;
};
@@ -246,7 +247,13 @@ export type NodeExecutionResult = {
node_exec_id: string;
node_id: string;
block_id: string;
status: "INCOMPLETE" | "QUEUED" | "RUNNING" | "COMPLETED" | "FAILED";
status:
| "INCOMPLETE"
| "QUEUED"
| "RUNNING"
| "COMPLETED"
| "TERMINATED"
| "FAILED";
input_data: { [key: string]: any };
output_data: { [key: string]: Array<any> };
add_time: Date;
@@ -506,3 +513,36 @@ export type StoreReviewCreate = {
score: number;
comments?: string;
};
// API Key Types
export enum APIKeyPermission {
EXECUTE_GRAPH = "EXECUTE_GRAPH",
READ_GRAPH = "READ_GRAPH",
EXECUTE_BLOCK = "EXECUTE_BLOCK",
READ_BLOCK = "READ_BLOCK",
}
export enum APIKeyStatus {
ACTIVE = "ACTIVE",
REVOKED = "REVOKED",
SUSPENDED = "SUSPENDED",
}
export interface APIKey {
id: string;
name: string;
prefix: string;
postfix: string;
status: APIKeyStatus;
permissions: APIKeyPermission[];
created_at: string;
last_used_at?: string;
revoked_at?: string;
description?: string;
}
export interface CreateAPIKeyResponse {
api_key: APIKey;
plain_text_key: string;
}

View File

@@ -1,7 +1,8 @@
import { Graph, Block, Node } from "./types";
import { Graph, Block, Node, BlockUIType } from "./types";
/** Creates a copy of the graph with all secrets removed */
export function safeCopyGraph(graph: Graph, block_defs: Block[]): Graph {
graph = removeAgentInputBlockValues(graph, block_defs);
return {
...graph,
nodes: graph.nodes.map((node) => {
@@ -18,3 +19,28 @@ export function safeCopyGraph(graph: Graph, block_defs: Block[]): Graph {
}),
};
}
export function removeAgentInputBlockValues(graph: Graph, blocks: Block[]) {
const inputBlocks = graph.nodes.filter(
(node) =>
blocks.find((b) => b.id === node.block_id)?.uiType === BlockUIType.INPUT,
);
const modifiedNodes = graph.nodes.map((node) => {
if (inputBlocks.find((inputNode) => inputNode.id === node.id)) {
return {
...node,
input_default: {
...node.input_default,
value: "",
},
};
}
return node;
});
return {
...graph,
nodes: modifiedNodes,
};
}

View File

@@ -7,33 +7,28 @@ export class LoginPage {
console.log("Attempting login with:", { email, password }); // Debug log
// Fill email
const emailInput = this.page.getByPlaceholder("user@email.com");
const emailInput = this.page.getByPlaceholder("m@example.com");
await emailInput.waitFor({ state: "visible" });
await emailInput.fill(email);
// Fill password
const passwordInput = this.page.getByPlaceholder("password");
const passwordInput = this.page.getByTitle("Password");
await passwordInput.waitFor({ state: "visible" });
await passwordInput.fill(password);
// Check terms
const termsCheckbox = this.page.getByLabel("I agree to the Terms of Use");
await termsCheckbox.waitFor({ state: "visible" });
await termsCheckbox.click();
// TODO: This is a workaround to wait for the page to load after filling the email and password
const emailInput2 = this.page.getByPlaceholder("user@email.com");
const emailInput2 = this.page.getByPlaceholder("m@example.com");
await emailInput2.waitFor({ state: "visible" });
await emailInput2.fill(email);
// Fill password
const passwordInput2 = this.page.getByPlaceholder("password");
const passwordInput2 = this.page.getByTitle("Password");
await passwordInput2.waitFor({ state: "visible" });
await passwordInput2.fill(password);
// Wait for the button to be ready
const loginButton = this.page.getByRole("button", {
name: "Log in",
name: "Login",
exact: true,
});
await loginButton.waitFor({ state: "visible" });

View File

@@ -0,0 +1,63 @@
import { z } from "zod";
export type LoginProvider = "google" | "github" | "discord";
export const loginFormSchema = z.object({
email: z
.string()
.email()
.max(128, "Email must contain at most 128 characters")
.trim(),
password: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
});
export const signupFormSchema = z
.object({
email: z
.string()
.email()
.max(128, "Email must contain at most 128 characters")
.trim(),
password: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
confirmPassword: z
.string()
.min(6, "Password must contain at least 6 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",
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const sendEmailFormSchema = z.object({
email: z
.string()
.email()
.max(128, "Email must contain at most 128 characters")
.trim(),
});
export const changePasswordFormSchema = z
.object({
password: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
confirmPassword: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});

View File

@@ -18,6 +18,8 @@ const config = {
mono: ["var(--font-geist-mono)"],
// Include the custom font family
neue: ['"PP Neue Montreal TT"', "sans-serif"],
poppin: ["var(--font-poppins)"],
inter: ["var(--font-inter)"],
},
colors: {
border: "hsl(var(--border))",

View File

@@ -1018,10 +1018,10 @@
"@types/tough-cookie" "^4.0.5"
tough-cookie "^4.1.4"
"@chromatic-com/storybook@^3.2.2":
version "3.2.2"
resolved "https://registry.yarnpkg.com/@chromatic-com/storybook/-/storybook-3.2.2.tgz#08754443de55618f802f88450c35266fd6d25db5"
integrity sha512-xmXt/GW0hAPbzNTrxYuVo43Adrtjue4DeVrsoIIEeJdGaPNNeNf+DHMlJKOBdlHmCnFUoe9R/0mLM9zUp5bKWw==
"@chromatic-com/storybook@^3.2.3":
version "3.2.3"
resolved "https://registry.yarnpkg.com/@chromatic-com/storybook/-/storybook-3.2.3.tgz#0f4d167ac80fcb38293a92c230c43446049b6758"
integrity sha512-3+hfANx79kIjP1qrOSLxpoAXOiYUA0S7A0WI0A24kASrv7USFNNW8etR5TjUilMb0LmqKUn3wDwUK2h6aceQ9g==
dependencies:
chromatic "^11.15.0"
filesize "^10.0.12"
@@ -1237,10 +1237,10 @@
dependencies:
"@hapi/hoek" "^9.0.0"
"@hookform/resolvers@^3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.9.1.tgz#a23883c40bfd449cb6c6ab5a0fa0729184c950ff"
integrity sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==
"@hookform/resolvers@^3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.10.0.tgz#7bfd18113daca4e57e27e1205b7d5a2d371aa59a"
integrity sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==
"@humanwhocodes/config-array@^0.13.0":
version "0.13.0"
@@ -1698,10 +1698,10 @@
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.20.tgz#0be2cc955f4eb837516e7d7382284cd5bc1d5a02"
integrity sha512-JfDpuOCB0UBKlEgEy/H6qcBSzHimn/YWjUHzKl1jMeUO+QVRdzmTTl8gFJaNO87c8DXmVKhFCtwxQ9acqB3+Pw==
"@next/eslint-plugin-next@15.1.0":
version "15.1.0"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.0.tgz#421b47ad0772e11b2d67416054675cd32f95b8b7"
integrity sha512-+jPT0h+nelBT6HC9ZCHGc7DgGVy04cv4shYdAe6tKlEbjQUtwU3LzQhzbDHQyY2m6g39m6B0kOFVuLGBrxxbGg==
"@next/eslint-plugin-next@15.1.3":
version "15.1.3"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.3.tgz#32777736af151577df52d83f25c0c22bc9f3cb5e"
integrity sha512-oeP1vnc5Cq9UoOb8SYHAEPbCXMzOgG70l+Zfd+Ie00R25FOm+CCVNrcIubJvB1tvBgakXE37MmqSycksXVPRqg==
dependencies:
fast-glob "3.3.1"
@@ -1750,10 +1750,10 @@
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.20.tgz#689bc7beb8005b73c95d926e7edfb7f73efc78f2"
integrity sha512-AFmqeLW6LtxeFTuoB+MXFeM5fm5052i3MU6xD0WzJDOwku6SkZaxb1bxjBaRC8uNqTRTSPl0yMFtjNowIVI67w==
"@next/third-parties@^15.0.4":
version "15.1.0"
resolved "https://registry.yarnpkg.com/@next/third-parties/-/third-parties-15.1.0.tgz#ab898927a006fe41ef90888220b51e22e11e110c"
integrity sha512-eiv8vTo5HJOE/LabnIjRNVpN0hvjXfqPrE7D/XecmWvHBs9KrIISxlb1NZizDMcvjGtnHkdupWsquM9ur25rYw==
"@next/third-parties@^15.1.3":
version "15.1.3"
resolved "https://registry.yarnpkg.com/@next/third-parties/-/third-parties-15.1.3.tgz#89c5c85b68d98a8787f0fa43a8d55ade7d6d5cf2"
integrity sha512-nz2mthh08xMRgNKRA+Z7lM1BqHqukGcFyu5z0nXFo3/KXsBgaPJkfnkfebw/YTqkxryV+aEttf/iAWDB6dUO6A==
dependencies:
third-party-capital "1.0.20"
@@ -1801,10 +1801,10 @@
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda"
integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==
"@opentelemetry/api-logs@0.52.1":
version "0.52.1"
resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz#52906375da4d64c206b0c4cb8ffa209214654ecc"
integrity sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==
"@opentelemetry/api-logs@0.53.0":
version "0.53.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz#c478cbd8120ec2547b64edfa03a552cfe42170be"
integrity sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==
dependencies:
"@opentelemetry/api" "^1.0.0"
@@ -2052,13 +2052,13 @@
semver "^7.5.2"
shimmer "^1.2.1"
"@opentelemetry/instrumentation@^0.49 || ^0.50 || ^0.51 || ^0.52.0":
version "0.52.1"
resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz#2e7e46a38bd7afbf03cf688c862b0b43418b7f48"
integrity sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==
"@opentelemetry/instrumentation@^0.49 || ^0.50 || ^0.51 || ^0.52.0 || ^0.53.0":
version "0.53.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.53.0.tgz#e6369e4015eb5112468a4d45d38dcada7dad892d"
integrity sha512-DMwg0hy4wzf7K73JJtl95m/e0boSoWhH07rfvHvYzQtBD3Bmv0Wc1x733vyZBqmFm8OjJD0/pfiUg1W3JjFX0A==
dependencies:
"@opentelemetry/api-logs" "0.52.1"
"@types/shimmer" "^1.0.2"
"@opentelemetry/api-logs" "0.53.0"
"@types/shimmer" "^1.2.0"
import-in-the-middle "^1.8.1"
require-in-the-middle "^7.1.1"
semver "^7.5.2"
@@ -2128,13 +2128,13 @@
schema-utils "^4.2.0"
source-map "^0.7.3"
"@prisma/instrumentation@5.19.1":
version "5.19.1"
resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-5.19.1.tgz#146319cf85f22b7a43296f0f40cfeac55516e66e"
integrity sha512-VLnzMQq7CWroL5AeaW0Py2huiNKeoMfCH3SUxstdzPrlWQi6UQ9UrfcbUkNHlVFqOMacqy8X/8YtE0kuKDpD9w==
"@prisma/instrumentation@5.22.0":
version "5.22.0"
resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-5.22.0.tgz#c39941046e9886e17bdb47dbac45946c24d579aa"
integrity sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==
dependencies:
"@opentelemetry/api" "^1.8"
"@opentelemetry/instrumentation" "^0.49 || ^0.50 || ^0.51 || ^0.52.0"
"@opentelemetry/instrumentation" "^0.49 || ^0.50 || ^0.51 || ^0.52.0 || ^0.53.0"
"@opentelemetry/sdk-trace-base" "^1.22"
"@radix-ui/number@1.1.0":
@@ -2630,51 +2630,51 @@
resolved "https://registry.yarnpkg.com/@scarf/scarf/-/scarf-1.4.0.tgz#3bbb984085dbd6d982494538b523be1ce6562972"
integrity sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==
"@sentry-internal/browser-utils@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.45.1.tgz#1ca97f1dfad8a7f5543074b4abd11dc6bc6a1c7b"
integrity sha512-sZwtP3zAzDsjUS7WkMW5VGbvSl7hGKTMc8gAJbpEsrybMxllIP13zzMRwpeFF11RnnvbrZ/FtAeX58Mvj0jahA==
"@sentry-internal/browser-utils@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.48.0.tgz#320713e29566929894de42d54152064ec19cc9b3"
integrity sha512-pLtu0Fa1Ou0v3M1OEO1MB1EONJVmXEGtoTwFRCO1RPQI2ulmkG6BikINClFG5IBpoYKZ33WkEXuM6U5xh+pdZg==
dependencies:
"@sentry/core" "8.45.1"
"@sentry/core" "8.48.0"
"@sentry-internal/feedback@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.45.1.tgz#8e8f3bd25408bce6c65d6a9176df065d1ba5b568"
integrity sha512-zCKptzki4SLnG+s8je8dgnppOKFjiiO4GVBc4fh7uL8zjNPBnxW8wK4SrPfAEKVYaHUzkKc5vixwUqcpmfLLGw==
"@sentry-internal/feedback@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.48.0.tgz#92d2301b0e7379716efae6c05bc4a4740687921a"
integrity sha512-6PwcJNHVPg0EfZxmN+XxVOClfQpv7MBAweV8t9i5l7VFr8sM/7wPNSeU/cG7iK19Ug9ZEkBpzMOe3G4GXJ5bpw==
dependencies:
"@sentry/core" "8.45.1"
"@sentry/core" "8.48.0"
"@sentry-internal/replay-canvas@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.45.1.tgz#748272da93a23a5323f9ef381ef3c7189a177447"
integrity sha512-qiPg6XwOwkiMMe/8Qf3EhXCqkSlSnWLlorYngIbdkV2klbWjd7vKnqkFJF4PnaS0g7kkZr7nh+MdzpyLyuj2Mw==
"@sentry-internal/replay-canvas@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.48.0.tgz#f88282b0594751407ca3016d0a63b133c2e37ac3"
integrity sha512-LdivLfBXXB9us1aAc6XaL7/L2Ob4vi3C/fEOXElehg3qHjX6q6pewiv5wBvVXGX1NfZTRvu+X11k6TZoxKsezw==
dependencies:
"@sentry-internal/replay" "8.45.1"
"@sentry/core" "8.45.1"
"@sentry-internal/replay" "8.48.0"
"@sentry/core" "8.48.0"
"@sentry-internal/replay@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.45.1.tgz#f05518adbe17566b1df6d2a2016d81f5f6c1c9a6"
integrity sha512-cOA9CodNSR9+hmICDaGIDUvWiwxQxeMHk/esbjB8uAW8HG4CYTG3CTYTZmlmou7DuysfMd4JNuFmDFBj+YU5/A==
"@sentry-internal/replay@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.48.0.tgz#2cc802178f6b0185581b61058f2541b9f3384a8b"
integrity sha512-csILVupc5RkrsTrncuUTGmlB56FQSFjXPYWG8I8yBTGlXEJ+o8oTuF6+55R4vbw3EIzBveXWi4kEBbnQlXW/eg==
dependencies:
"@sentry-internal/browser-utils" "8.45.1"
"@sentry/core" "8.45.1"
"@sentry-internal/browser-utils" "8.48.0"
"@sentry/core" "8.48.0"
"@sentry/babel-plugin-component-annotate@2.22.7":
version "2.22.7"
resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.22.7.tgz#604c7e33d48528a13477e7af597c4d5fca51b8bd"
integrity sha512-aa7XKgZMVl6l04NY+3X7BP7yvQ/s8scn8KzQfTLrGRarziTlMGrsCOBQtCNWXOPEbtxAIHpZ9dsrAn5EJSivOQ==
"@sentry/browser@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.45.1.tgz#a7b9af5deefb57778fbb526cb8e43fa2f6ae3d97"
integrity sha512-/KvYhQSRg8m9kotG8h9FrfXCWRlebrvdfXKjj1oE9SyZ2LmR8Ze9AcEw1qzsBsa1F1D/a5FQbUJahSoLBkaQPA==
"@sentry/browser@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.48.0.tgz#bdd7793ddd3ae7a65d595066bde93fbb63ce8b9d"
integrity sha512-fuuVULB5/1vI8NoIwXwR3xwhJJqk+y4RdSdajExGF7nnUDBpwUJyXsmYJnOkBO+oLeEs58xaCpotCKiPUNnE3g==
dependencies:
"@sentry-internal/browser-utils" "8.45.1"
"@sentry-internal/feedback" "8.45.1"
"@sentry-internal/replay" "8.45.1"
"@sentry-internal/replay-canvas" "8.45.1"
"@sentry/core" "8.45.1"
"@sentry-internal/browser-utils" "8.48.0"
"@sentry-internal/feedback" "8.48.0"
"@sentry-internal/replay" "8.48.0"
"@sentry-internal/replay-canvas" "8.48.0"
"@sentry/core" "8.48.0"
"@sentry/bundler-plugin-core@2.22.7":
version "2.22.7"
@@ -2744,35 +2744,35 @@
"@sentry/cli-win32-i686" "2.39.1"
"@sentry/cli-win32-x64" "2.39.1"
"@sentry/core@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.45.1.tgz#da3b13a8fd2276e8a1d4f5a38f9b8a0ed6647b49"
integrity sha512-1fGmkr0paZshh38mD29c4CfkRkgFoYDaAGyDLoGYfTbEph/lU8RHB2HWzN93McqNdMEhl1DRRyqIasUZoPlqSA==
"@sentry/core@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.48.0.tgz#3bb8d06305f0ec7c873453844687deafdeab168b"
integrity sha512-VGwYgTfLpvJ5LRO5A+qWo1gpo6SfqaGXL9TOzVgBucAdpzbrYHpZ87sEarDVq/4275uk1b0S293/mfsskFczyw==
"@sentry/nextjs@^8":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-8.45.1.tgz#4bad8fe78709c8efe63f34d5b4ed86bfa086efd6"
integrity sha512-EcUuQHGAk8cheuPfjRCXacjoD5ClDsB9qssYBVIvJozNEW5C+A0eJwPj/Qd1C05tdcJ6MMdMRv0NrTHdCN1v1A==
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-8.48.0.tgz#4882fe8a92833e333c13d276d62648295769106a"
integrity sha512-eKbhUW+9KCyK2xIO09iUI3KszfCxtmKgamSYED+N5bb1DzySjDur6BabHFBgA7BcQmYKpTSj/lVxznFNw3H1uQ==
dependencies:
"@opentelemetry/api" "^1.9.0"
"@opentelemetry/semantic-conventions" "^1.28.0"
"@rollup/plugin-commonjs" "28.0.1"
"@sentry-internal/browser-utils" "8.45.1"
"@sentry/core" "8.45.1"
"@sentry/node" "8.45.1"
"@sentry/opentelemetry" "8.45.1"
"@sentry/react" "8.45.1"
"@sentry/vercel-edge" "8.45.1"
"@sentry-internal/browser-utils" "8.48.0"
"@sentry/core" "8.48.0"
"@sentry/node" "8.48.0"
"@sentry/opentelemetry" "8.48.0"
"@sentry/react" "8.48.0"
"@sentry/vercel-edge" "8.48.0"
"@sentry/webpack-plugin" "2.22.7"
chalk "3.0.0"
resolve "1.22.8"
rollup "3.29.5"
stacktrace-parser "^0.1.10"
"@sentry/node@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-8.45.1.tgz#24ebe7cb6a1ddc3d95602945a9574978797ac6f9"
integrity sha512-xvlXifM/FSOQdLAqQBuo04SiOh7RP8rRRr+c5G/YbBtgJA867Pve0X8JZK2BJpDZ3OrGvzXV1Ubnt9ao4rBfYA==
"@sentry/node@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-8.48.0.tgz#d4d1374431028af7663a06bf7268bf40a9bf1fa0"
integrity sha512-pnprAuUOc8cxnJdZA09hutHXNsbQZoDgzf3zPyXMNx0ewB/RviFMOgfe7ViX1mIB/oVrcFenXBgO5uvTd7JwPg==
dependencies:
"@opentelemetry/api" "^1.9.0"
"@opentelemetry/context-async-hooks" "^1.29.0"
@@ -2805,34 +2805,34 @@
"@opentelemetry/resources" "^1.29.0"
"@opentelemetry/sdk-trace-base" "^1.29.0"
"@opentelemetry/semantic-conventions" "^1.28.0"
"@prisma/instrumentation" "5.19.1"
"@sentry/core" "8.45.1"
"@sentry/opentelemetry" "8.45.1"
"@prisma/instrumentation" "5.22.0"
"@sentry/core" "8.48.0"
"@sentry/opentelemetry" "8.48.0"
import-in-the-middle "^1.11.2"
"@sentry/opentelemetry@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry/opentelemetry/-/opentelemetry-8.45.1.tgz#4c8e686818fb6af45ed486c341902affd9f110cf"
integrity sha512-khnR5TS21ksITTXmXnpniRN7brlZS5RNNQuMZ9n3MYi/L1/s9LT73skNh1gder28OV6ZxGUgrTZ+1dtKqn9tig==
"@sentry/opentelemetry@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry/opentelemetry/-/opentelemetry-8.48.0.tgz#718e7942724d64ffe8e901941b0e4050fa07780b"
integrity sha512-1JLXgmIvD3T7xn9ypwWW0V3GirNy4BN2fOUbZau/nUX/Jj5DttSoPn7x7xTaPSpfaA24PiP93zXmJEfZvCk00Q==
dependencies:
"@sentry/core" "8.45.1"
"@sentry/core" "8.48.0"
"@sentry/react@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-8.45.1.tgz#75afd1508cfdda9dbe7109ffbbb58767464bf56a"
integrity sha512-ooMR2Lt4YSc5CMJklBKiL/mb+uidcZMpflxUvVUbtWMif+PqTUkfPRyICv6vs7muxK9i84Rr4iCkyZ4ns4H27A==
"@sentry/react@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-8.48.0.tgz#0550a9a4d123d20c680d94bdaa8a8dbeb3b2661e"
integrity sha512-J8XAUOJYbsjXnowTEXE+zWJWLWUzQGP8kMb+smoGdRzFJwwXKrbE709Kr/Boz6rK48EbbRT4UUINoTbHgL3RHQ==
dependencies:
"@sentry/browser" "8.45.1"
"@sentry/core" "8.45.1"
"@sentry/browser" "8.48.0"
"@sentry/core" "8.48.0"
hoist-non-react-statics "^3.3.2"
"@sentry/vercel-edge@8.45.1":
version "8.45.1"
resolved "https://registry.yarnpkg.com/@sentry/vercel-edge/-/vercel-edge-8.45.1.tgz#d32b6302d5f062c2a641cbcf2be8614e99a9b30c"
integrity sha512-taCKf2ESNzpYRwuaikS17YA2upqzzPbemT+If+kOafgSUrLEq2YRYRvgoruyFLmjOGO9+634+HmGo7Nyn5CGjQ==
"@sentry/vercel-edge@8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@sentry/vercel-edge/-/vercel-edge-8.48.0.tgz#e2d429dc3f24b47f4bdae1e34856d3b258365b03"
integrity sha512-5bxMCTkadnvJvCC363ZXEdAHaWS/RAAvsI+8RAFObJO0tUemjKrgbHM/1YcvLRZSuBs6BSn9RjDipzzlFgtBWw==
dependencies:
"@opentelemetry/api" "^1.9.0"
"@sentry/core" "8.45.1"
"@sentry/core" "8.48.0"
"@sentry/webpack-plugin@2.22.7":
version "2.22.7"
@@ -3213,10 +3213,10 @@
"@storybook/react-dom-shim" "8.4.7"
"@storybook/theming" "8.4.7"
"@storybook/test-runner@^0.20.1":
version "0.20.1"
resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.20.1.tgz#e2efa6266d512312a6b810db376da2919008cccd"
integrity sha512-3WU/th/uncIR6vpQDK9hKjiZjmczsluoLbgkRV7ufxY9IgHCGcbIjvT5EPS+XZIaOrNGjaPsyB5cE1okKn9iSA==
"@storybook/test-runner@^0.21.0":
version "0.21.0"
resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.21.0.tgz#31e7a6878e15a3f4d5555c57a135dd4d13fce9c4"
integrity sha512-aG2QvKXSIjMN1CA9PJK/esnidZWgt1gAkfo9Kqf8+NqBSsmP/2GyL5vxu1lkRFO/4qCv5JenNZ5Uj6ie4b2oag==
dependencies:
"@babel/core" "^7.22.5"
"@babel/generator" "^7.22.5"
@@ -3257,17 +3257,17 @@
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.4.7.tgz#c308f6a883999bd35e87826738ab8a76515932b5"
integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw==
"@supabase/auth-js@2.67.1":
version "2.67.1"
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.1.tgz#b72217136df61d645dcfb7b12c7db8cbb7875a4c"
integrity sha512-1SRZG9VkLFz4rtiyEc1l49tMq9jTYu4wJt3pMQEWi7yshZFIBdVH1o5sshk1plQd5LY6GcrPIpCydM2gGDxchA==
"@supabase/auth-js@2.67.3":
version "2.67.3"
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.3.tgz#a1f5eb22440b0cdbf87fe2ecae662a8dd8bb2028"
integrity sha512-NJDaW8yXs49xMvWVOkSIr8j46jf+tYHV0wHhrwOaLLMZSFO4g6kKAf+MfzQ2RaD06OCUkUHIzctLAxjTgEVpzw==
dependencies:
"@supabase/node-fetch" "^2.6.14"
"@supabase/functions-js@2.4.3":
version "2.4.3"
resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.3.tgz#ac1c696d3a1ebe00f60d5cea69b208078678ef8b"
integrity sha512-sOLXy+mWRyu4LLv1onYydq+10mNRQ4rzqQxNhbrKLTLTcdcmS9hbWif0bGz/NavmiQfPs4ZcmQJp4WqOXlR4AQ==
"@supabase/functions-js@2.4.4":
version "2.4.4"
resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.4.tgz#45fcd94d546bdfa66d01f93a796ca0304ec154b8"
integrity sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==
dependencies:
"@supabase/node-fetch" "^2.6.14"
@@ -3311,12 +3311,12 @@
"@supabase/node-fetch" "^2.6.14"
"@supabase/supabase-js@^2.47.8":
version "2.47.8"
resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.47.8.tgz#6471a356b694e14170a00e6582bdbd0126944ec6"
integrity sha512-2GjK8/PrGJYDVBcjqGyM2irBLMQXvvkJLbS8VFPlym2uuNz+pPMnwLbNf5njkknUTy3PamjgIRoADpuPPPA6oA==
version "2.47.10"
resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.47.10.tgz#310ce81dc734116f9445dbce7f9341ae1c24d834"
integrity sha512-vJfPF820Ho5WILYHfKiBykDQ1SB9odTHrRZ0JxHfuLMC8GRvv21YLkUZQK7/rSVCkLvD6/ZwMWaOAfdUd//guw==
dependencies:
"@supabase/auth-js" "2.67.1"
"@supabase/functions-js" "2.4.3"
"@supabase/auth-js" "2.67.3"
"@supabase/functions-js" "2.4.4"
"@supabase/node-fetch" "2.6.15"
"@supabase/postgrest-js" "1.17.7"
"@supabase/realtime-js" "2.11.2"
@@ -3714,10 +3714,10 @@
resolved "https://registry.yarnpkg.com/@types/negotiator/-/negotiator-0.6.3.tgz#29e8fce64e35f57f6fe9c624f8e4ed304357745a"
integrity sha512-JkXTOdKs5MF086b/pt8C3+yVp3iDUwG635L7oCH6HvJvvr6lSUU5oe/gLXnPEfYRROHjJIPgCV6cuAg8gGkntQ==
"@types/node@*", "@types/node@^22.0.0", "@types/node@^22.9.0":
version "22.10.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9"
integrity sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==
"@types/node@*", "@types/node@^22.0.0", "@types/node@^22.10.5":
version "22.10.5"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.5.tgz#95af89a3fb74a2bb41ef9927f206e6472026e48b"
integrity sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==
dependencies:
undici-types "~6.20.0"
@@ -3798,7 +3798,7 @@
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e"
integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==
"@types/shimmer@^1.0.2", "@types/shimmer@^1.2.0":
"@types/shimmer@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded"
integrity sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==
@@ -4990,10 +4990,10 @@ chokidar@^3.5.3, chokidar@^3.6.0:
optionalDependencies:
fsevents "~2.3.2"
chromatic@^11.12.5, chromatic@^11.15.0:
version "11.20.2"
resolved "https://registry.yarnpkg.com/chromatic/-/chromatic-11.20.2.tgz#10b309179cdc0b9195a5b68970366f9ebe67dfd1"
integrity sha512-c+M3HVl5Y60c7ipGTZTyeWzWubRW70YsJ7PPDpO1D735ib8+Lu3yGF90j61pvgkXGngpkTPHZyBw83lcu2JMxA==
chromatic@^11.15.0, chromatic@^11.22.0:
version "11.22.0"
resolved "https://registry.yarnpkg.com/chromatic/-/chromatic-11.22.0.tgz#9c2b05a0c5a94c5c0cc2b6be6969c112ac4543d0"
integrity sha512-u1kAPR9lj9aFzsCp0iWPXBbsKgcxFU7iJO6mFbgNHGVg+YPBqiJMuvgB8EQHdNbHjk5amFnGnIz/Ww8fK3t9Hw==
chrome-trace-event@^1.0.2:
version "1.0.4"
@@ -5192,10 +5192,10 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concurrently@^9.0.1:
version "9.1.0"
resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.0.tgz#8da6d609f4321752912dab9be8710232ac496aa0"
integrity sha512-VxkzwMAn4LP7WyMnJNbHN5mKV9L2IbyDjpzemKr99sXNR3GqRNMMHdm7prV1ws9wg7ETj6WUkNOigZVsptwbgg==
concurrently@^9.1.2:
version "9.1.2"
resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.2.tgz#22d9109296961eaee773e12bfb1ce9a66bc9836c"
integrity sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==
dependencies:
chalk "^4.1.2"
lodash "^4.17.21"
@@ -5540,6 +5540,11 @@ data-view-byte-offset@^1.0.0:
es-errors "^1.3.0"
is-data-view "^1.0.1"
date-fns-jalali@^4.1.0-0:
version "4.1.0-0"
resolved "https://registry.yarnpkg.com/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz#9c7fb286004fab267a300d3e9f1ada9f10b4b6b0"
integrity sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==
date-fns@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
@@ -6120,12 +6125,12 @@ escape-string-regexp@^4.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-config-next@15.1.0:
version "15.1.0"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-15.1.0.tgz#25a9a076b059905fd0cf3f6f832771724dfcbbdf"
integrity sha512-gADO+nKVseGso3DtOrYX9H7TxB/MuX7AUYhMlvQMqLYvUWu4HrOQuU7cC1HW74tHIqkAvXdwgAz3TCbczzSEXw==
eslint-config-next@15.1.3:
version "15.1.3"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-15.1.3.tgz#7656b47591745bcdbd60d396282924d89f82eea6"
integrity sha512-wGYlNuWnh4ujuKtZvH+7B2Z2vy9nONZE6ztd+DKF7hAsIabkrxmD4TzYHzASHENo42lmz2tnT2B+zN2sOHvpJg==
dependencies:
"@next/eslint-plugin-next" "15.1.0"
"@next/eslint-plugin-next" "15.1.3"
"@rushstack/eslint-patch" "^1.10.3"
"@typescript-eslint/eslint-plugin" "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0"
"@typescript-eslint/parser" "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -6241,10 +6246,10 @@ eslint-plugin-react@^7.37.0:
string.prototype.matchall "^4.0.11"
string.prototype.repeat "^1.0.0"
eslint-plugin-storybook@^0.11.0:
version "0.11.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-storybook/-/eslint-plugin-storybook-0.11.1.tgz#4ef4f3550855fdc4a902296dfc278340ec287506"
integrity sha512-yGKpAYkBm/Q2hZg476vRUAvd9lAccjjSvzU5nYy3BSQbKTPy7uopx7JEpwk2vSuw4weTMZzWF64z9/gp/K5RCg==
eslint-plugin-storybook@^0.11.2:
version "0.11.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-storybook/-/eslint-plugin-storybook-0.11.2.tgz#a46f8fa2b87d15f66251e832a10d5481fc73a028"
integrity sha512-0Z4DUklJrC+GHjCRXa7PYfPzWC15DaVnwaOYenpgXiCEijXPZkLKCms+rHhtoRcWccP7Z8DpOOaP1gc3P9oOwg==
dependencies:
"@storybook/csf" "^0.1.11"
"@typescript-eslint/utils" "^8.8.1"
@@ -6695,13 +6700,13 @@ forwarded-parse@2.1.2:
resolved "https://registry.yarnpkg.com/forwarded-parse/-/forwarded-parse-2.1.2.tgz#08511eddaaa2ddfd56ba11138eee7df117a09325"
integrity sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==
framer-motion@^11.15.0:
version "11.15.0"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-11.15.0.tgz#93e5d1839d500ba9cab1d617959a36142a61212b"
integrity sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w==
framer-motion@^11.16.0:
version "11.16.0"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-11.16.0.tgz#6592689bc8d6422207a55e48297eae99e9aa2eb2"
integrity sha512-oL2AWqLQuw0+CNEUa0sz3mWC/n3i147CckvpQn8bLRs30b+HxTxlRi0YR2FpHHhAbWV7DKjNdHU42KHLfBWh/g==
dependencies:
motion-dom "^11.14.3"
motion-utils "^11.14.3"
motion-dom "^11.16.0"
motion-utils "^11.16.0"
tslib "^2.4.0"
fromentries@^1.2.0:
@@ -8398,10 +8403,10 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
lucide-react@^0.468.0:
version "0.468.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.468.0.tgz#830c1bfd905575ddd23b832baa420c87db166910"
integrity sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==
lucide-react@^0.469.0:
version "0.469.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.469.0.tgz#f16936ca6521482fef754a7eabb310e6c68e1482"
integrity sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==
lz-string@^1.5.0:
version "1.5.0"
@@ -8884,15 +8889,17 @@ moment@^2.30.1:
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
motion-dom@^11.14.3:
version "11.14.3"
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-11.14.3.tgz#725c72c0f1d0b632e42fdd8d13b69ecf9fe202c0"
integrity sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA==
motion-dom@^11.16.0:
version "11.16.0"
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-11.16.0.tgz#1766e4f9ada72c30ba8f3e698774372fcbcc95b3"
integrity sha512-4bmEwajSdrljzDAYpu6ceEdtI4J5PH25fmN8YSx7Qxk6OMrC10CXM0D5y+VO/pFZjhmCvm2bGf7Rus482kwhzA==
dependencies:
motion-utils "^11.16.0"
motion-utils@^11.14.3:
version "11.14.3"
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-11.14.3.tgz#cd4a413463739498411f82abb67b3dd58768b0f8"
integrity sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==
motion-utils@^11.16.0:
version "11.16.0"
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-11.16.0.tgz#e75865442278be49e411ca9105c9139edc572811"
integrity sha512-ngdWPjg31rD4WGXFi0eZ00DQQqKKu04QExyv/ymlC+3k+WIgYVFbt6gS5JsFPbJODTF/r8XiE/X+SsoT9c0ocw==
ms@^2.1.1, ms@^2.1.3:
version "2.1.3"
@@ -9895,13 +9902,14 @@ react-confetti@^6.1.0:
dependencies:
tween-functions "^1.2.0"
react-day-picker@^9.4.4:
version "9.4.4"
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-9.4.4.tgz#1c514c86489ede20046eec957c68a7b31542bd49"
integrity sha512-1s+jA/bFYtoxhhr8M0kkFHLiMTSII6qU8UfDFprRAUStTVHljLTjg4oarvAngPlQ1cQAC+LUb0k/qMc+jjhmxw==
react-day-picker@^9.5.0:
version "9.5.0"
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-9.5.0.tgz#2ae36e85d6506026d72e350f49b5607d011cfd6f"
integrity sha512-WmJnPFVLnKh5Qscm7wavMNg86rqPverSWjx+zgK8/ZmGRSQ8c8OoqW10RI+AzAfT2atIxImpCUU2R9Z7Xb2SUA==
dependencies:
"@date-fns/tz" "^1.2.0"
date-fns "^4.1.0"
date-fns-jalali "^4.1.0-0"
react-docgen-typescript@^2.2.2:
version "2.2.2"
@@ -9932,10 +9940,10 @@ react-docgen@^7.0.0:
loose-envify "^1.1.0"
scheduler "^0.23.2"
react-hook-form@^7.54.0:
version "7.54.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.54.1.tgz#e99c2a55a5e4859fb47a8f55adf66b34d6ac331d"
integrity sha512-PUNzFwQeQ5oHiiTUO7GO/EJXGEtuun2Y1A59rLnZBBj+vNEOWt/3ERTiG1/zt7dVeJEM+4vDX/7XQ/qanuvPMg==
react-hook-form@^7.54.2:
version "7.54.2"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.54.2.tgz#8c26ed54c71628dff57ccd3c074b1dd377cfb211"
integrity sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==
react-icons@^5.4.0:
version "5.4.0"
@@ -9962,10 +9970,10 @@ react-lifecycles-compat@^3.0.0:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-markdown@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.1.tgz#c05ddbff67fd3b3f839f8c648e6fb35d022397d1"
integrity sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==
react-markdown@^9.0.3:
version "9.0.3"
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.3.tgz#c12bf60dad05e9bf650b86bcc612d80636e8456e"
integrity sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw==
dependencies:
"@types/hast" "^3.0.0"
devlop "^1.0.0"
@@ -9978,10 +9986,10 @@ react-markdown@^9.0.1:
unist-util-visit "^5.0.0"
vfile "^6.0.0"
react-modal@^3.16.1:
version "3.16.1"
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.16.1.tgz#34018528fc206561b1a5467fc3beeaddafb39b2b"
integrity sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==
react-modal@^3.16.3:
version "3.16.3"
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.16.3.tgz#c412d41915782e3c261253435d01468e2439b11b"
integrity sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==
dependencies:
exenv "^1.2.0"
prop-types "^15.7.2"
@@ -11035,20 +11043,20 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
tailwind-merge@^2.5.5:
version "2.5.5"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.5.tgz#98167859b856a2a6b8d2baf038ee171b9d814e39"
integrity sha512-0LXunzzAZzo0tEPxV3I297ffKZPlKDrjj7NXphC8V5ak9yHC5zRmxnOe2m/Rd/7ivsOMJe3JZ2JVocoDdQTRBA==
tailwind-merge@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==
tailwindcss-animate@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
tailwindcss@^3.4.15:
version "3.4.16"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.16.tgz#35a7c3030844d6000fc271878db4096b6a8d2ec9"
integrity sha512-TI4Cyx7gDiZ6r44ewaJmt0o6BrMCT5aK5e0rmJ/G9Xq3w7CX/5VXl/zIPEJZFUK5VEqwByyhqNPycPlvcK4ZNw==
tailwindcss@^3.4.17:
version "3.4.17"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63"
integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==
dependencies:
"@alloc/quick-lru" "^5.2.0"
arg "^5.0.2"
@@ -11553,10 +11561,10 @@ utila@~0.4:
resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
integrity sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==
uuid@^11.0.3:
version "11.0.3"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.3.tgz#248451cac9d1a4a4128033e765d137e2b2c49a3d"
integrity sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==
uuid@^11.0.4:
version "11.0.4"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.4.tgz#37943977894ef806d2919a7ca3f89d6e23c60bac"
integrity sha512-IzL6VtTTYcAhA/oghbFJ1Dkmqev+FpQWnCBaKq/gUluLxliWvO8DPFWfIviRmYbtaavtSQe4WBL++rFjdcGWEg==
uuid@^8.0.0, uuid@^8.3.2:
version "8.3.2"

View File

@@ -1,12 +0,0 @@
DB_USER=postgres
DB_PASS=your-super-secret-and-long-postgres-password
DB_NAME=postgres
DB_PORT=5432
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:${DB_PORT}/${DB_NAME}?connect_timeout=60&schema=market"
SENTRY_DSN=https://11d0640fef35640e0eb9f022eb7d7626@o4505260022104064.ingest.us.sentry.io/4507890252447744
ENABLE_AUTH=true
SUPABASE_JWT_SECRET=our-super-secret-jwt-token-with-at-least-32-characters-long
BACKEND_CORS_ALLOW_ORIGINS="http://localhost:3000,http://127.0.0.1:3000"
APP_ENV=local

View File

@@ -1,6 +0,0 @@
database.db
database.db-journal
build/
config.json
secrets/*
!secrets/.gitkeep

View File

@@ -1,72 +0,0 @@
FROM python:3.11.10-slim-bookworm AS builder
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
WORKDIR /app
RUN echo 'Acquire::http::Pipeline-Depth 0;\nAcquire::http::No-Cache true;\nAcquire::BrokenProxy true;\n' > /etc/apt/apt.conf.d/99fixbadproxy
RUN apt-get update --allow-releaseinfo-change --fix-missing
# Install build dependencies
RUN apt-get install -y build-essential
RUN apt-get install -y libpq5
RUN apt-get install -y libz-dev
RUN apt-get install -y libssl-dev
ENV POETRY_VERSION=1.8.3 \
POETRY_HOME="/opt/poetry" \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false
ENV PATH="$POETRY_HOME/bin:$PATH"
# Upgrade pip and setuptools to fix security vulnerabilities
RUN pip3 install --upgrade pip setuptools
RUN pip3 install poetry
# Copy and install dependencies
COPY autogpt_platform/autogpt_libs /app/autogpt_platform/autogpt_libs
COPY autogpt_platform/market/poetry.lock autogpt_platform/market/pyproject.toml /app/autogpt_platform/market/
WORKDIR /app/autogpt_platform/market
RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi
# Generate Prisma client
COPY autogpt_platform/market /app/autogpt_platform/market
RUN poetry config virtualenvs.create false \
&& poetry run prisma generate
FROM python:3.11.10-slim-bookworm AS server_dependencies
WORKDIR /app
# Upgrade pip and setuptools to fix security vulnerabilities
RUN pip3 install --upgrade pip setuptools
# Copy only necessary files from builder
COPY --from=builder /app /app
COPY --from=builder /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy Prisma binaries
COPY --from=builder /root/.cache/prisma-python/binaries /root/.cache/prisma-python/binaries
ENV PATH="/app/.venv/bin:$PATH"
RUN mkdir -p /app/autogpt_platform/autogpt_libs
RUN mkdir -p /app/autogpt_platform/market
COPY autogpt_platform/autogpt_libs /app/autogpt_platform/autogpt_libs
COPY autogpt_platform/market /app/autogpt_platform/market
WORKDIR /app/autogpt_platform/market
FROM server_dependencies AS server
ENV DATABASE_URL=""
ENV PORT=8015
CMD ["poetry", "run", "app"]

View File

@@ -1,37 +0,0 @@
# AutoGPT Agent Marketplace
## Overview
AutoGPT Agent Marketplace is an open-source platform for autonomous AI agents. This project aims to create a user-friendly, accessible marketplace where users can discover, utilize, and contribute to a diverse ecosystem of AI solutions.
## Vision
Our vision is to empower users with customizable and free AI agents, fostering an open-source community that drives innovation in AI automation across various industries.
## Key Features
- Agent Discovery and Search
- Agent Listings with Detailed Information
- User Profiles
- Data Protection and Compliance
## Getting Started
To get started with the AutoGPT Agent Marketplace, follow these steps:
- Copy `.env.example` to `.env` and fill in the required environment variables
- Run `poetry run setup`
- Run `poetry run populate`
- Run `poetry run app`
## Poetry Run Commands
This section outlines the available command line scripts for this project, configured using Poetry. You can execute these scripts directly using Poetry. Each command performs a specific operation as described below:
- `poetry run format`: Runs the formatting script to ensure code consistency.
- `poetry run lint`: Executes the linting script to identify and fix potential code issues.
- `poetry run app`: Starts the main application.
- `poetry run setup`: Runs the setup script to configure the database.
- `poetry run populate`: Populates the database with initial data using the specified script.
To run any of these commands, ensure Poetry is installed on your system and execute the commands from the project's root directory.

View File

@@ -1,16 +0,0 @@
version: "3"
services:
postgres:
image: ankane/pgvector:latest
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
PGUSER: ${DB_USER}
healthcheck:
test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
interval: 10s
timeout: 5s
retries: 5
ports:
- "${DB_PORT}:5432"

View File

@@ -1,97 +0,0 @@
import contextlib
import logging.config
import os
import dotenv
import fastapi
import fastapi.middleware.cors
import fastapi.middleware.gzip
import prisma
import prometheus_fastapi_instrumentator
import sentry_sdk
import sentry_sdk.integrations.asyncio
import sentry_sdk.integrations.fastapi
import sentry_sdk.integrations.starlette
import market.config
import market.routes.admin
import market.routes.agents
import market.routes.analytics
import market.routes.search
import market.routes.submissions
dotenv.load_dotenv()
logging.config.dictConfig(market.config.LogConfig().model_dump())
if os.environ.get("SENTRY_DSN"):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
traces_sample_rate=1.0,
profiles_sample_rate=1.0,
enable_tracing=True,
environment=os.environ.get("RUN_ENV", default="CLOUD").lower(),
integrations=[
sentry_sdk.integrations.starlette.StarletteIntegration(
transaction_style="url"
),
sentry_sdk.integrations.fastapi.FastApiIntegration(transaction_style="url"),
sentry_sdk.integrations.asyncio.AsyncioIntegration(),
],
)
db_client = prisma.Prisma(auto_register=True)
@contextlib.asynccontextmanager
async def lifespan(app: fastapi.FastAPI):
await db_client.connect()
yield
await db_client.disconnect()
docs_url = "/docs"
app = fastapi.FastAPI(
title="Marketplace API",
description="AutoGPT Marketplace API is a service that allows users to share AI agents.",
summary="Maketplace API",
version="0.1",
lifespan=lifespan,
root_path="/api/v1/market",
docs_url=docs_url,
)
app.add_middleware(fastapi.middleware.gzip.GZipMiddleware, minimum_size=1000)
app.add_middleware(
middleware_class=fastapi.middleware.cors.CORSMiddleware,
allow_origins=os.environ.get(
"BACKEND_CORS_ALLOW_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000"
).split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(market.routes.agents.router, tags=["agents"])
app.include_router(market.routes.search.router, tags=["search"])
app.include_router(market.routes.submissions.router, tags=["submissions"])
app.include_router(market.routes.admin.router, prefix="/admin", tags=["admin"])
app.include_router(
market.routes.analytics.router, prefix="/analytics", tags=["analytics"]
)
@app.get("/health")
def health():
return fastapi.responses.HTMLResponse(
content="<h1>Marketplace API</h1>", status_code=200
)
@app.get("/")
def default():
return fastapi.responses.HTMLResponse(
content="<h1>Marketplace API</h1>", status_code=200
)
prometheus_fastapi_instrumentator.Instrumentator().instrument(app).expose(app)

View File

@@ -1,30 +0,0 @@
from pydantic import BaseModel
class LogConfig(BaseModel):
"""Logging configuration to be set for the server"""
LOGGER_NAME: str = "marketplace"
LOG_FORMAT: str = "%(levelprefix)s | %(asctime)s | %(message)s"
LOG_LEVEL: str = "DEBUG"
# Logging config
version: int = 1
disable_existing_loggers: bool = False
formatters: dict = {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": LOG_FORMAT,
"datefmt": "%Y-%m-%d %H:%M:%S",
},
}
handlers: dict = {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
}
loggers: dict = {
LOGGER_NAME: {"handlers": ["default"], "level": LOG_LEVEL},
}

View File

@@ -1,725 +0,0 @@
import datetime
import typing
import fuzzywuzzy.fuzz
import prisma.enums
import prisma.errors
import prisma.models
import prisma.types
import pydantic
import market.model
import market.utils.extension_types
class AgentQueryError(Exception):
"""Custom exception for agent query errors"""
pass
class TopAgentsDBResponse(pydantic.BaseModel):
"""
Represents a response containing a list of top agents.
Attributes:
analytics (list[AgentResponse]): The list of top agents.
total_count (int): The total count of agents.
page (int): The current page number.
page_size (int): The number of agents per page.
total_pages (int): The total number of pages.
"""
analytics: list[prisma.models.AnalyticsTracker]
total_count: int
page: int
page_size: int
total_pages: int
class FeaturedAgentResponse(pydantic.BaseModel):
"""
Represents a response containing a list of featured agents.
Attributes:
featured_agents (list[FeaturedAgent]): The list of featured agents.
total_count (int): The total count of featured agents.
page (int): The current page number.
page_size (int): The number of agents per page.
total_pages (int): The total number of pages.
"""
featured_agents: list[prisma.models.FeaturedAgent]
total_count: int
page: int
page_size: int
total_pages: int
async def delete_agent(agent_id: str) -> prisma.models.Agents | None:
"""
Delete an agent from the database.
Args:
agent_id (str): The ID of the agent to delete.
Returns:
prisma.models.Agents | None: The deleted agent if found, None otherwise.
Raises:
AgentQueryError: If there is an error deleting the agent from the database.
"""
try:
deleted_agent = await prisma.models.Agents.prisma().delete(
where={"id": agent_id}
)
return deleted_agent
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def create_agent_entry(
name: str,
description: str,
author: str,
keywords: typing.List[str],
categories: typing.List[str],
graph: prisma.Json,
submission_state: prisma.enums.SubmissionStatus = prisma.enums.SubmissionStatus.PENDING,
):
"""
Create a new agent entry in the database.
Args:
name (str): The name of the agent.
description (str): The description of the agent.
author (str): The author of the agent.
keywords (List[str]): The keywords associated with the agent.
categories (List[str]): The categories associated with the agent.
graph (dict): The graph data of the agent.
Returns:
dict: The newly created agent entry.
Raises:
AgentQueryError: If there is an error creating the agent entry.
"""
try:
agent = await prisma.models.Agents.prisma().create(
data={
"name": name,
"description": description,
"author": author,
"keywords": keywords,
"categories": categories,
"graph": graph,
"AnalyticsTracker": {"create": {"downloads": 0, "views": 0}},
"submissionStatus": submission_state,
}
)
return agent
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def update_agent_entry(
agent_id: str,
version: int,
submission_state: prisma.enums.SubmissionStatus,
comments: str | None = None,
) -> prisma.models.Agents | None:
"""
Update an existing agent entry in the database.
Args:
agent_id (str): The ID of the agent.
version (int): The version of the agent.
submission_state (prisma.enums.SubmissionStatus): The submission state of the agent.
"""
try:
agent = await prisma.models.Agents.prisma().update(
where={"id": agent_id},
data={
"version": version,
"submissionStatus": submission_state,
"submissionReviewDate": datetime.datetime.now(datetime.timezone.utc),
"submissionReviewComments": comments,
},
)
return agent
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Agent Update Failed Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def get_agents(
page: int = 1,
page_size: int = 10,
name: str | None = None,
keyword: str | None = None,
category: str | None = None,
description: str | None = None,
description_threshold: int = 60,
submission_status: prisma.enums.SubmissionStatus = prisma.enums.SubmissionStatus.APPROVED,
sort_by: str = "createdAt",
sort_order: typing.Literal["desc"] | typing.Literal["asc"] = "desc",
):
"""
Retrieve a list of agents from the database based on the provided filters and pagination parameters.
Args:
page (int, optional): The page number to retrieve. Defaults to 1.
page_size (int, optional): The number of agents per page. Defaults to 10.
name (str, optional): Filter agents by name. Defaults to None.
keyword (str, optional): Filter agents by keyword. Defaults to None.
category (str, optional): Filter agents by category. Defaults to None.
description (str, optional): Filter agents by description. Defaults to None.
description_threshold (int, optional): The minimum fuzzy search threshold for the description. Defaults to 60.
sort_by (str, optional): The field to sort the agents by. Defaults to "createdAt".
sort_order (str, optional): The sort order ("asc" or "desc"). Defaults to "desc".
Returns:
dict: A dictionary containing the list of agents, total count, current page number, page size, and total number of pages.
"""
try:
# Define the base query
query = {}
# Add optional filters
if name:
query["name"] = {"contains": name, "mode": "insensitive"}
if keyword:
query["keywords"] = {"has": keyword}
if category:
query["categories"] = {"has": category}
query["submissionStatus"] = submission_status
# Define sorting
order = {sort_by: sort_order}
# Calculate pagination
skip = (page - 1) * page_size
# Execute the query
try:
agents = await prisma.models.Agents.prisma().find_many(
where=query, # type: ignore
order=order, # type: ignore
skip=skip,
take=page_size,
)
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
# Apply fuzzy search on description if provided
if description:
try:
filtered_agents = []
for agent in agents:
if (
agent.description
and fuzzywuzzy.fuzz.partial_ratio(
description.lower(), agent.description.lower()
)
>= description_threshold
):
filtered_agents.append(agent)
agents = filtered_agents
except AttributeError as e:
raise AgentQueryError(f"Error during fuzzy search: {str(e)}")
# Get total count for pagination info
total_count = len(agents)
return {
"agents": agents,
"total_count": total_count,
"page": page,
"page_size": page_size,
"total_pages": (total_count + page_size - 1) // page_size,
}
except AgentQueryError as e:
# Log the error or handle it as needed
raise e
except ValueError as e:
raise AgentQueryError(f"Invalid input parameter: {str(e)}")
except Exception as e:
# Catch any other unexpected exceptions
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def get_agent_details(agent_id: str, version: int | None = None):
"""
Retrieve agent details from the database.
Args:
agent_id (str): The ID of the agent.
version (int | None, optional): The version of the agent. Defaults to None.
Returns:
dict: The agent details.
Raises:
AgentQueryError: If the agent is not found or if there is an error querying the database.
"""
try:
query = {"id": agent_id}
if version is not None:
query["version"] = version # type: ignore
agent = await prisma.models.Agents.prisma().find_first(where=query) # type: ignore
if not agent:
raise AgentQueryError("Agent not found")
return agent
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def search_db(
query: str,
page: int = 1,
page_size: int = 10,
categories: typing.List[str] | None = None,
description_threshold: int = 60,
sort_by: str = "rank",
sort_order: typing.Literal["desc"] | typing.Literal["asc"] = "desc",
submission_status: prisma.enums.SubmissionStatus = prisma.enums.SubmissionStatus.APPROVED,
) -> market.model.ListResponse[market.utils.extension_types.AgentsWithRank]:
"""Perform a search for agents based on the provided query string.
Args:
query (str): the search string
page (int, optional): page for searching. Defaults to 1.
page_size (int, optional): the number of results to return. Defaults to 10.
categories (List[str] | None, optional): list of category filters. Defaults to None.
description_threshold (int, optional): number of characters to return. Defaults to 60.
sort_by (str, optional): sort by option. Defaults to "rank".
sort_order ("asc" | "desc", optional): the sort order. Defaults to "desc".
Raises:
AgentQueryError: Raises an error if the query fails.
AgentQueryError: Raises if an unexpected error occurs.
Returns:
List[AgentsWithRank]: List of agents matching the search criteria.
"""
try:
offset = (page - 1) * page_size
category_filter = "1=1"
if categories:
category_conditions = [f"'{cat}' = ANY(categories)" for cat in categories]
category_filter = "AND (" + " OR ".join(category_conditions) + ")"
# Construct the ORDER BY clause based on the sort_by parameter
if sort_by in ["createdAt", "updatedAt"]:
order_by_clause = f'"{sort_by}" {sort_order.upper()}, rank DESC'
elif sort_by == "name":
order_by_clause = f"name {sort_order.upper()}, rank DESC"
else:
order_by_clause = 'rank DESC, "createdAt" DESC'
submission_status_filter = f""""submissionStatus" = '{submission_status}'"""
sql_query = f"""
WITH query AS (
SELECT to_tsquery(string_agg(lexeme || ':*', ' & ' ORDER BY positions)) AS q
FROM unnest(to_tsvector('{query}'))
)
SELECT
id,
"createdAt",
"updatedAt",
version,
name,
LEFT(description, {description_threshold}) AS description,
author,
keywords,
categories,
graph,
"submissionStatus",
"submissionDate",
CASE
WHEN query.q::text = '' THEN 1.0
ELSE COALESCE(ts_rank(CAST(search AS tsvector), query.q), 0.0)
END AS rank
FROM market."Agents", query
WHERE
(query.q::text = '' OR search @@ query.q)
AND {category_filter}
AND {submission_status_filter}
ORDER BY {order_by_clause}
LIMIT {page_size}
OFFSET {offset};
"""
results = await prisma.client.get_client().query_raw(
query=sql_query,
model=market.utils.extension_types.AgentsWithRank,
)
class CountResponse(pydantic.BaseModel):
count: int
count_query = f"""
WITH query AS (
SELECT to_tsquery(string_agg(lexeme || ':*', ' & ' ORDER BY positions)) AS q
FROM unnest(to_tsvector('{query}'))
)
SELECT COUNT(*)
FROM market."Agents", query
WHERE (search @@ query.q OR query.q = '') AND {category_filter} AND {submission_status_filter};
"""
total_count = await prisma.client.get_client().query_first(
query=count_query,
model=CountResponse,
)
total_count = total_count.count if total_count else 0
return market.model.ListResponse(
items=results,
total_count=total_count,
page=page,
page_size=page_size,
total_pages=(total_count + page_size - 1) // page_size,
)
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def get_top_agents_by_downloads(
page: int = 1,
page_size: int = 10,
submission_status: prisma.enums.SubmissionStatus = prisma.enums.SubmissionStatus.APPROVED,
) -> market.model.ListResponse[prisma.models.AnalyticsTracker]:
"""Retrieve the top agents by download count.
Args:
page (int, optional): The page number. Defaults to 1.
page_size (int, optional): The number of agents per page. Defaults to 10.
Returns:
dict: A dictionary containing the list of agents, total count, current page number, page size, and total number of pages.
"""
try:
# Calculate pagination
skip = (page - 1) * page_size
# Execute the query
try:
# Agents with no downloads will not be included in the results... is this the desired behavior?
analytics = await prisma.models.AnalyticsTracker.prisma().find_many(
include={"agent": True},
order={"downloads": "desc"},
where={"agent": {"is": {"submissionStatus": submission_status}}},
skip=skip,
take=page_size,
)
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
try:
total_count = await prisma.models.AnalyticsTracker.prisma().count(
where={"agent": {"is": {"submissionStatus": submission_status}}},
)
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
return market.model.ListResponse(
items=analytics,
total_count=total_count,
page=page,
page_size=page_size,
total_pages=(total_count + page_size - 1) // page_size,
)
except AgentQueryError as e:
# Log the error or handle it as needed
raise e from e
except ValueError as e:
raise AgentQueryError(f"Invalid input parameter: {str(e)}") from e
except Exception as e:
# Catch any other unexpected exceptions
raise AgentQueryError(f"Unexpected error occurred: {str(e)}") from e
async def set_agent_featured(
agent_id: str, is_active: bool = True, featured_categories: list[str] = ["featured"]
) -> prisma.models.FeaturedAgent:
"""Set an agent as featured in the database.
Args:
agent_id (str): The ID of the agent.
category (str, optional): The category to set the agent as featured. Defaults to "featured".
Raises:
AgentQueryError: If there is an error setting the agent as featured.
"""
try:
agent = await prisma.models.Agents.prisma().find_unique(where={"id": agent_id})
if not agent:
raise AgentQueryError(f"Agent with ID {agent_id} not found.")
featured = await prisma.models.FeaturedAgent.prisma().upsert(
where={"agentId": agent_id},
data={
"update": {
"featuredCategories": featured_categories,
"isActive": is_active,
},
"create": {
"featuredCategories": featured_categories,
"isActive": is_active,
"agent": {"connect": {"id": agent_id}},
},
},
)
return featured
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def get_featured_agents(
category: str = "featured",
page: int = 1,
page_size: int = 10,
submission_status: prisma.enums.SubmissionStatus = prisma.enums.SubmissionStatus.APPROVED,
) -> FeaturedAgentResponse:
"""Retrieve a list of featured agents from the database based on the provided category.
Args:
category (str, optional): The category of featured agents to retrieve. Defaults to "featured".
page (int, optional): The page number to retrieve. Defaults to 1.
page_size (int, optional): The number of agents per page. Defaults to 10.
Returns:
dict: A dictionary containing the list of featured agents, total count, current page number, page size, and total number of pages.
"""
try:
# Calculate pagination
skip = (page - 1) * page_size
# Execute the query
try:
featured_agents = await prisma.models.FeaturedAgent.prisma().find_many(
where={
"featuredCategories": {"has": category},
"isActive": True,
"agent": {"is": {"submissionStatus": submission_status}},
},
include={"agent": {"include": {"AnalyticsTracker": True}}},
skip=skip,
take=page_size,
)
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
# Get total count for pagination info
total_count = len(featured_agents)
return FeaturedAgentResponse(
featured_agents=featured_agents,
total_count=total_count,
page=page,
page_size=page_size,
total_pages=(total_count + page_size - 1) // page_size,
)
except AgentQueryError as e:
# Log the error or handle it as needed
raise e from e
except ValueError as e:
raise AgentQueryError(f"Invalid input parameter: {str(e)}") from e
except Exception as e:
# Catch any other unexpected exceptions
raise AgentQueryError(f"Unexpected error occurred: {str(e)}") from e
async def remove_featured_category(
agent_id: str, category: str
) -> prisma.models.FeaturedAgent | None:
"""Adds a featured category to an agent.
Args:
agent_id (str): The ID of the agent.
category (str): The category to add to the agent.
Returns:
FeaturedAgentResponse: The updated list of featured agents.
"""
try:
# get the existing categories
featured_agent = await prisma.models.FeaturedAgent.prisma().find_unique(
where={"agentId": agent_id},
include={"agent": True},
)
if not featured_agent:
raise AgentQueryError(f"Agent with ID {agent_id} not found.")
# remove the category from the list
featured_agent.featuredCategories.remove(category)
featured_agent = await prisma.models.FeaturedAgent.prisma().update(
where={"agentId": agent_id},
data={"featuredCategories": featured_agent.featuredCategories},
)
return featured_agent
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def add_featured_category(
agent_id: str, category: str
) -> prisma.models.FeaturedAgent | None:
"""Removes a featured category from an agent.
Args:
agent_id (str): The ID of the agent.
category (str): The category to remove from the agent.
Returns:
FeaturedAgentResponse: The updated list of featured agents.
"""
try:
featured_agent = await prisma.models.FeaturedAgent.prisma().update(
where={"agentId": agent_id},
data={"featuredCategories": {"push": [category]}},
)
return featured_agent
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def get_agent_featured(agent_id: str) -> prisma.models.FeaturedAgent | None:
"""Retrieve an agent's featured categories from the database.
Args:
agent_id (str): The ID of the agent.
Returns:
FeaturedAgentResponse: The list of featured agents.
"""
try:
featured_agent = await prisma.models.FeaturedAgent.prisma().find_unique(
where={"agentId": agent_id},
)
return featured_agent
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def get_not_featured_agents(
page: int = 1, page_size: int = 10
) -> typing.List[prisma.models.Agents]:
"""
Retrieve a list of not featured agents from the database.
"""
try:
agents = await prisma.client.get_client().query_raw(
query=f"""
SELECT
"market"."Agents".id,
"market"."Agents"."createdAt",
"market"."Agents"."updatedAt",
"market"."Agents".version,
"market"."Agents".name,
LEFT("market"."Agents".description, 500) AS description,
"market"."Agents".author,
"market"."Agents".keywords,
"market"."Agents".categories,
"market"."Agents".graph,
"market"."Agents"."submissionStatus",
"market"."Agents"."submissionDate",
"market"."Agents".search::text AS search
FROM "market"."Agents"
LEFT JOIN "market"."FeaturedAgent" ON "market"."Agents"."id" = "market"."FeaturedAgent"."agentId"
WHERE ("market"."FeaturedAgent"."agentId" IS NULL OR "market"."FeaturedAgent"."featuredCategories" = '{{}}')
AND "market"."Agents"."submissionStatus" = 'APPROVED'
ORDER BY "market"."Agents"."createdAt" DESC
LIMIT {page_size} OFFSET {page_size * (page - 1)}
""",
model=prisma.models.Agents,
)
return agents
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")
async def get_all_categories() -> market.model.CategoriesResponse:
"""
Retrieve all unique categories from the database.
Returns:
CategoriesResponse: A list of unique categories.
"""
try:
agents = await prisma.models.Agents.prisma().find_many(distinct=["categories"])
# Aggregate categories on the Python side
all_categories = set()
for agent in agents:
all_categories.update(agent.categories)
unique_categories = sorted(list(all_categories))
return market.model.CategoriesResponse(unique_categories=unique_categories)
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception:
# Return an empty list of categories in case of unexpected errors
return market.model.CategoriesResponse(unique_categories=[])
async def create_agent_installed_event(
event_data: market.model.AgentInstalledFromMarketplaceEventData,
):
try:
await prisma.models.InstallTracker.prisma().create(
data={
"installedAgentId": event_data.installed_agent_id,
"marketplaceAgentId": event_data.marketplace_agent_id,
"installationLocation": prisma.enums.InstallationLocation(
event_data.installation_location.name
),
}
)
except prisma.errors.PrismaError as e:
raise AgentQueryError(f"Database query failed: {str(e)}")
except Exception as e:
raise AgentQueryError(f"Unexpected error occurred: {str(e)}")

View File

@@ -1,161 +0,0 @@
import datetime
import typing
from enum import Enum
from typing import Generic, Literal, TypeVar, Union
import prisma.enums
import pydantic
class InstallationLocation(str, Enum):
LOCAL = "local"
CLOUD = "cloud"
class AgentInstalledFromMarketplaceEventData(pydantic.BaseModel):
marketplace_agent_id: str
installed_agent_id: str
installation_location: InstallationLocation
class AgentInstalledFromTemplateEventData(pydantic.BaseModel):
template_id: str
installed_agent_id: str
installation_location: InstallationLocation
class AgentInstalledFromMarketplaceEvent(pydantic.BaseModel):
event_name: Literal["agent_installed_from_marketplace"]
event_data: AgentInstalledFromMarketplaceEventData
class AgentInstalledFromTemplateEvent(pydantic.BaseModel):
event_name: Literal["agent_installed_from_template"]
event_data: AgentInstalledFromTemplateEventData
AnalyticsEvent = Union[
AgentInstalledFromMarketplaceEvent, AgentInstalledFromTemplateEvent
]
class AnalyticsRequest(pydantic.BaseModel):
event: AnalyticsEvent
class AddAgentRequest(pydantic.BaseModel):
graph: dict[str, typing.Any]
author: str
keywords: list[str]
categories: list[str]
class SubmissionReviewRequest(pydantic.BaseModel):
agent_id: str
version: int
status: prisma.enums.SubmissionStatus
comments: str | None
class AgentResponse(pydantic.BaseModel):
"""
Represents a response from an agent.
Attributes:
id (str): The ID of the agent.
name (str, optional): The name of the agent.
description (str, optional): The description of the agent.
author (str, optional): The author of the agent.
keywords (list[str]): The keywords associated with the agent.
categories (list[str]): The categories the agent belongs to.
version (int): The version of the agent.
createdAt (str): The creation date of the agent.
updatedAt (str): The last update date of the agent.
"""
id: str
name: typing.Optional[str]
description: typing.Optional[str]
author: typing.Optional[str]
keywords: list[str]
categories: list[str]
version: int
createdAt: datetime.datetime
updatedAt: datetime.datetime
submissionStatus: str
views: int = 0
downloads: int = 0
class AgentDetailResponse(pydantic.BaseModel):
"""
Represents the response data for an agent detail.
Attributes:
id (str): The ID of the agent.
name (Optional[str]): The name of the agent.
description (Optional[str]): The description of the agent.
author (Optional[str]): The author of the agent.
keywords (List[str]): The keywords associated with the agent.
categories (List[str]): The categories the agent belongs to.
version (int): The version of the agent.
createdAt (str): The creation date of the agent.
updatedAt (str): The last update date of the agent.
graph (Dict[str, Any]): The graph data of the agent.
"""
id: str
name: typing.Optional[str]
description: typing.Optional[str]
author: typing.Optional[str]
keywords: list[str]
categories: list[str]
version: int
createdAt: datetime.datetime
updatedAt: datetime.datetime
graph: dict[str, typing.Any]
class FeaturedAgentResponse(pydantic.BaseModel):
"""
Represents the response data for an agent detail.
"""
agentId: str
featuredCategories: list[str]
createdAt: datetime.datetime
updatedAt: datetime.datetime
isActive: bool
class CategoriesResponse(pydantic.BaseModel):
"""
Represents the response data for a list of categories.
Attributes:
unique_categories (list[str]): The list of unique categories.
"""
unique_categories: list[str]
T = TypeVar("T")
class ListResponse(pydantic.BaseModel, Generic[T]):
"""
Represents a list response.
Attributes:
items (list[T]): The list of items.
total_count (int): The total count of items.
page (int): The current page number.
page_size (int): The number of items per page.
total_pages (int): The total number of pages.
"""
items: list[T]
total_count: int
page: int
page_size: int
total_pages: int

View File

@@ -1,286 +0,0 @@
import logging
import typing
import autogpt_libs.auth
import fastapi
import prisma
import prisma.enums
import prisma.models
import market.db
import market.model
logger = logging.getLogger("marketplace")
router = fastapi.APIRouter()
@router.delete("/agent/{agent_id}", response_model=market.model.AgentResponse)
async def delete_agent(
agent_id: str,
user: autogpt_libs.auth.User = fastapi.Depends(
autogpt_libs.auth.requires_admin_user
),
):
"""
Delete an agent and all related records from the database.
Args:
agent_id (str): The ID of the agent to delete.
Returns:
market.model.AgentResponse: The deleted agent's data.
Raises:
fastapi.HTTPException: If the agent is not found or if there's an error during deletion.
"""
try:
deleted_agent = await market.db.delete_agent(agent_id)
if deleted_agent:
return market.model.AgentResponse(**deleted_agent.dict())
else:
raise fastapi.HTTPException(status_code=404, detail="Agent not found")
except market.db.AgentQueryError as e:
logger.error(f"Error deleting agent: {e}")
raise fastapi.HTTPException(status_code=500, detail=str(e))
except Exception as e:
logger.error(f"Unexpected error deleting agent: {e}")
raise fastapi.HTTPException(
status_code=500, detail="An unexpected error occurred"
)
@router.post("/agent", response_model=market.model.AgentResponse)
async def create_agent_entry(
request: market.model.AddAgentRequest,
user: autogpt_libs.auth.User = fastapi.Depends(
autogpt_libs.auth.requires_admin_user
),
):
"""
A basic endpoint to create a new agent entry in the database.
"""
try:
agent = await market.db.create_agent_entry(
request.graph["name"],
request.graph["description"],
request.author,
request.keywords,
request.categories,
prisma.Json(request.graph),
)
return fastapi.responses.PlainTextResponse(agent.model_dump_json())
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
@router.post("/agent/featured/{agent_id}")
async def set_agent_featured(
agent_id: str,
categories: list[str] = fastapi.Query(
default=["featured"],
description="The categories to set the agent as featured in",
),
user: autogpt_libs.auth.User = fastapi.Depends(
autogpt_libs.auth.requires_admin_user
),
) -> market.model.FeaturedAgentResponse:
"""
A basic endpoint to set an agent as featured in the database.
"""
try:
agent = await market.db.set_agent_featured(
agent_id, is_active=True, featured_categories=categories
)
return market.model.FeaturedAgentResponse(**agent.model_dump())
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
@router.get("/agent/featured/{agent_id}")
async def get_agent_featured(
agent_id: str,
user: autogpt_libs.auth.User = fastapi.Depends(
autogpt_libs.auth.requires_admin_user
),
) -> market.model.FeaturedAgentResponse | None:
"""
A basic endpoint to get an agent as featured in the database.
"""
try:
agent = await market.db.get_agent_featured(agent_id)
if agent:
return market.model.FeaturedAgentResponse(**agent.model_dump())
else:
return None
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
@router.delete("/agent/featured/{agent_id}")
async def unset_agent_featured(
agent_id: str,
category: str = "featured",
user: autogpt_libs.auth.User = fastapi.Depends(
autogpt_libs.auth.requires_admin_user
),
) -> market.model.FeaturedAgentResponse | None:
"""
A basic endpoint to unset an agent as featured in the database.
"""
try:
featured = await market.db.remove_featured_category(agent_id, category=category)
if featured:
return market.model.FeaturedAgentResponse(**featured.model_dump())
else:
return None
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
@router.get("/agent/not-featured")
async def get_not_featured_agents(
page: int = fastapi.Query(1, ge=1, description="Page number"),
page_size: int = fastapi.Query(
10, ge=1, le=100, description="Number of items per page"
),
user: autogpt_libs.auth.User = fastapi.Depends(
autogpt_libs.auth.requires_admin_user
),
) -> market.model.ListResponse[market.model.AgentResponse]:
"""
A basic endpoint to get all not featured agents in the database.
"""
try:
agents = await market.db.get_not_featured_agents(page=page, page_size=page_size)
return market.model.ListResponse(
items=[
market.model.AgentResponse(**agent.model_dump()) for agent in agents
],
total_count=len(agents),
page=page,
page_size=page_size,
total_pages=999,
)
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
@router.get(
"/agent/submissions",
response_model=market.model.ListResponse[market.model.AgentResponse],
)
async def get_agent_submissions(
page: int = fastapi.Query(1, ge=1, description="Page number"),
page_size: int = fastapi.Query(
10, ge=1, le=100, description="Number of items per page"
),
name: typing.Optional[str] = fastapi.Query(
None, description="Filter by agent name"
),
keyword: typing.Optional[str] = fastapi.Query(
None, description="Filter by keyword"
),
category: typing.Optional[str] = fastapi.Query(
None, description="Filter by category"
),
description: typing.Optional[str] = fastapi.Query(
None, description="Fuzzy search in description"
),
description_threshold: int = fastapi.Query(
60, ge=0, le=100, description="Fuzzy search threshold"
),
sort_by: str = fastapi.Query("createdAt", description="Field to sort by"),
sort_order: typing.Literal["asc", "desc"] = fastapi.Query(
"desc", description="Sort order (asc or desc)"
),
user: autogpt_libs.auth.User = fastapi.Depends(
autogpt_libs.auth.requires_admin_user
),
) -> market.model.ListResponse[market.model.AgentResponse]:
logger.info("Getting agent submissions")
try:
result = await market.db.get_agents(
page=page,
page_size=page_size,
name=name,
keyword=keyword,
category=category,
description=description,
description_threshold=description_threshold,
sort_by=sort_by,
sort_order=sort_order,
submission_status=prisma.enums.SubmissionStatus.PENDING,
)
agents = [
market.model.AgentResponse(**agent.dict()) for agent in result["agents"]
]
return market.model.ListResponse(
items=agents,
total_count=result["total_count"],
page=result["page"],
page_size=result["page_size"],
total_pages=result["total_pages"],
)
except market.db.AgentQueryError as e:
logger.error(f"Error getting agent submissions: {e}")
raise fastapi.HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error getting agent submissions: {e}")
raise fastapi.HTTPException(
status_code=500, detail=f"An unexpected error occurred: {e}"
)
@router.post("/agent/submissions")
async def review_submission(
review_request: market.model.SubmissionReviewRequest,
user: autogpt_libs.auth.User = fastapi.Depends(
autogpt_libs.auth.requires_admin_user
),
) -> prisma.models.Agents | None:
"""
A basic endpoint to review a submission in the database.
"""
logger.info(
f"Reviewing submission: {review_request.agent_id}, {review_request.version}"
)
try:
agent = await market.db.update_agent_entry(
agent_id=review_request.agent_id,
version=review_request.version,
submission_state=review_request.status,
comments=review_request.comments,
)
return agent
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
@router.get("/categories")
async def get_categories() -> market.model.CategoriesResponse:
"""
A basic endpoint to get all available categories.
"""
try:
categories = await market.db.get_all_categories()
return categories
except Exception as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))

View File

@@ -1,76 +0,0 @@
import datetime
from unittest import mock
import autogpt_libs.auth.middleware
import fastapi
import fastapi.testclient
import prisma.enums
import prisma.models
import market.app
client = fastapi.testclient.TestClient(market.app.app)
async def override_auth_middleware(request: fastapi.Request):
return {"sub": "3e53486c-cf57-477e-ba2a-cb02dc828e1a", "role": "admin"}
market.app.app.dependency_overrides[autogpt_libs.auth.middleware.auth_middleware] = (
override_auth_middleware
)
def test_get_submissions():
with mock.patch("market.db.get_agents") as mock_get_agents:
mock_get_agents.return_value = {
"agents": [],
"total_count": 0,
"page": 1,
"page_size": 10,
"total_pages": 0,
}
response = client.get(
"/api/v1/market/admin/agent/submissions?page=1&page_size=10&description_threshold=60&sort_by=createdAt&sort_order=desc",
headers={"Bearer": ""},
)
assert response.status_code == 200
assert response.json() == {
"agents": [],
"total_count": 0,
"page": 1,
"page_size": 10,
"total_pages": 0,
}
def test_review_submission():
with mock.patch("market.db.update_agent_entry") as mock_update_agent_entry:
mock_update_agent_entry.return_value = prisma.models.Agents(
id="aaa-bbb-ccc",
version=1,
createdAt=datetime.datetime.fromisoformat("2021-10-01T00:00:00+00:00"),
updatedAt=datetime.datetime.fromisoformat("2021-10-01T00:00:00+00:00"),
submissionStatus=prisma.enums.SubmissionStatus.APPROVED,
submissionDate=datetime.datetime.fromisoformat("2021-10-01T00:00:00+00:00"),
submissionReviewComments="Looks good",
submissionReviewDate=datetime.datetime.fromisoformat(
"2021-10-01T00:00:00+00:00"
),
keywords=["test"],
categories=["test"],
graph='{"name": "test", "description": "test"}', # type: ignore
)
response = client.post(
"/api/v1/market/admin/agent/submissions",
headers={
"Authorization": "Bearer token"
}, # Assuming you need an authorization token
json={
"agent_id": "aaa-bbb-ccc",
"version": 1,
"status": "APPROVED",
"comments": "Looks good",
},
)
assert response.status_code == 200

View File

@@ -1,368 +0,0 @@
import json
import tempfile
import typing
import fastapi
import fastapi.responses
import prisma
import prisma.enums
import market.db
import market.model
import market.utils.analytics
router = fastapi.APIRouter()
@router.get(
"/agents", response_model=market.model.ListResponse[market.model.AgentResponse]
)
async def list_agents(
page: int = fastapi.Query(1, ge=1, description="Page number"),
page_size: int = fastapi.Query(
10, ge=1, le=100, description="Number of items per page"
),
name: typing.Optional[str] = fastapi.Query(
None, description="Filter by agent name"
),
keyword: typing.Optional[str] = fastapi.Query(
None, description="Filter by keyword"
),
category: typing.Optional[str] = fastapi.Query(
None, description="Filter by category"
),
description: typing.Optional[str] = fastapi.Query(
None, description="Fuzzy search in description"
),
description_threshold: int = fastapi.Query(
60, ge=0, le=100, description="Fuzzy search threshold"
),
sort_by: str = fastapi.Query("createdAt", description="Field to sort by"),
sort_order: typing.Literal["asc", "desc"] = fastapi.Query(
"desc", description="Sort order (asc or desc)"
),
submission_status: prisma.enums.SubmissionStatus = fastapi.Query(
default=prisma.enums.SubmissionStatus.APPROVED,
description="Filter by submission status",
),
):
"""
Retrieve a list of agents based on the provided filters.
Args:
page (int): Page number (default: 1).
page_size (int): Number of items per page (default: 10, min: 1, max: 100).
name (str, optional): Filter by agent name.
keyword (str, optional): Filter by keyword.
category (str, optional): Filter by category.
description (str, optional): Fuzzy search in description.
description_threshold (int): Fuzzy search threshold (default: 60, min: 0, max: 100).
sort_by (str): Field to sort by (default: "createdAt").
sort_order (str): Sort order (asc or desc) (default: "desc").
submission_status (str): Filter by submission status (default: "APPROVED").
Returns:
market.model.ListResponse[market.model.AgentResponse]: A response containing the list of agents and pagination information.
Raises:
HTTPException: If there is a client error (status code 400) or an unexpected error (status code 500).
"""
try:
result = await market.db.get_agents(
page=page,
page_size=page_size,
name=name,
keyword=keyword,
category=category,
description=description,
description_threshold=description_threshold,
sort_by=sort_by,
sort_order=sort_order,
submission_status=submission_status,
)
agents = [
market.model.AgentResponse(**agent.dict()) for agent in result["agents"]
]
return market.model.ListResponse(
items=agents,
total_count=result["total_count"],
page=result["page"],
page_size=result["page_size"],
total_pages=result["total_pages"],
)
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(
status_code=500, detail=f"An unexpected error occurred: {e}"
)
@router.get("/agents/{agent_id}", response_model=market.model.AgentDetailResponse)
async def get_agent_details_endpoint(
background_tasks: fastapi.BackgroundTasks,
agent_id: str = fastapi.Path(..., description="The ID of the agent to retrieve"),
version: typing.Optional[int] = fastapi.Query(
None, description="Specific version of the agent"
),
):
"""
Retrieve details of a specific agent.
Args:
agent_id (str): The ID of the agent to retrieve.
version (Optional[int]): Specific version of the agent (default: None).
Returns:
market.model.AgentDetailResponse: The response containing the agent details.
Raises:
HTTPException: If the agent is not found or an unexpected error occurs.
"""
try:
agent = await market.db.get_agent_details(agent_id, version)
background_tasks.add_task(market.utils.analytics.track_view, agent_id)
return market.model.AgentDetailResponse(**agent.model_dump())
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
)
@router.get("/agents/{agent_id}/download")
async def download_agent(
background_tasks: fastapi.BackgroundTasks,
agent_id: str = fastapi.Path(..., description="The ID of the agent to retrieve"),
version: typing.Optional[int] = fastapi.Query(
None, description="Specific version of the agent"
),
):
"""
Download details of a specific agent.
NOTE: This is the same as agent details, however it also triggers
the "download" tracking. We don't actually want to download a file though
Args:
agent_id (str): The ID of the agent to retrieve.
version (Optional[int]): Specific version of the agent (default: None).
Returns:
market.model.AgentDetailResponse: The response containing the agent details.
Raises:
HTTPException: If the agent is not found or an unexpected error occurs.
"""
try:
agent = await market.db.get_agent_details(agent_id, version)
background_tasks.add_task(market.utils.analytics.track_download, agent_id)
return market.model.AgentDetailResponse(**agent.model_dump())
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
)
@router.get("/agents/{agent_id}/download-file")
async def download_agent_file(
background_tasks: fastapi.BackgroundTasks,
agent_id: str = fastapi.Path(..., description="The ID of the agent to download"),
version: typing.Optional[int] = fastapi.Query(
None, description="Specific version of the agent"
),
) -> fastapi.responses.FileResponse:
"""
Download the agent file by streaming its content.
Args:
agent_id (str): The ID of the agent to download.
version (Optional[int]): Specific version of the agent to download.
Returns:
StreamingResponse: A streaming response containing the agent's graph data.
Raises:
HTTPException: If the agent is not found or an unexpected error occurs.
"""
agent = await market.db.get_agent_details(agent_id, version)
graph_data: prisma.Json = agent.graph
background_tasks.add_task(market.utils.analytics.track_download, agent_id)
file_name = f"agent_{agent_id}_v{version or 'latest'}.json"
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp_file:
tmp_file.write(json.dumps(graph_data))
tmp_file.flush()
return fastapi.responses.FileResponse(
tmp_file.name, filename=file_name, media_type="application/json"
)
# top agents by downloads
@router.get(
"/top-downloads/agents",
response_model=market.model.ListResponse[market.model.AgentResponse],
)
async def top_agents_by_downloads(
page: int = fastapi.Query(1, ge=1, description="Page number"),
page_size: int = fastapi.Query(
10, ge=1, le=100, description="Number of items per page"
),
submission_status: prisma.enums.SubmissionStatus = fastapi.Query(
default=prisma.enums.SubmissionStatus.APPROVED,
description="Filter by submission status",
),
) -> market.model.ListResponse[market.model.AgentResponse]:
"""
Retrieve a list of top agents based on the number of downloads.
Args:
page (int): Page number (default: 1).
page_size (int): Number of items per page (default: 10, min: 1, max: 100).
submission_status (str): Filter by submission status (default: "APPROVED").
Returns:
market.model.ListResponse[market.model.AgentResponse]: A response containing the list of top agents and pagination information.
Raises:
HTTPException: If there is a client error (status code 400) or an unexpected error (status code 500).
"""
try:
result = await market.db.get_top_agents_by_downloads(
page=page,
page_size=page_size,
submission_status=submission_status,
)
ret = market.model.ListResponse(
total_count=result.total_count,
page=result.page,
page_size=result.page_size,
total_pages=result.total_pages,
items=[
market.model.AgentResponse(
id=item.agent.id,
name=item.agent.name,
description=item.agent.description,
author=item.agent.author,
keywords=item.agent.keywords,
categories=item.agent.categories,
version=item.agent.version,
createdAt=item.agent.createdAt,
updatedAt=item.agent.updatedAt,
views=item.views,
downloads=item.downloads,
submissionStatus=item.agent.submissionStatus,
)
for item in result.items
if item.agent is not None
],
)
return ret
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
raise fastapi.HTTPException(
status_code=500, detail=f"An unexpected error occurred: {e}"
) from e
@router.get(
"/featured/agents",
response_model=market.model.ListResponse[market.model.AgentResponse],
)
async def get_featured_agents(
category: str = fastapi.Query(
"featured", description="Category of featured agents"
),
page: int = fastapi.Query(1, ge=1, description="Page number"),
page_size: int = fastapi.Query(
10, ge=1, le=100, description="Number of items per page"
),
submission_status: prisma.enums.SubmissionStatus = fastapi.Query(
default=prisma.enums.SubmissionStatus.APPROVED,
description="Filter by submission status",
),
):
"""
Retrieve a list of featured agents based on the provided category.
Args:
category (str): Category of featured agents (default: "featured").
page (int): Page number (default: 1).
page_size (int): Number of items per page (default: 10, min: 1, max: 100).
submission_status (str): Filter by submission status (default: "APPROVED").
Returns:
market.model.ListResponse[market.model.AgentResponse]: A response containing the list of featured agents and pagination information.
Raises:
HTTPException: If there is a client error (status code 400) or an unexpected error (status code 500).
"""
try:
result = await market.db.get_featured_agents(
category=category,
page=page,
page_size=page_size,
submission_status=submission_status,
)
ret = market.model.ListResponse(
total_count=result.total_count,
page=result.page,
page_size=result.page_size,
total_pages=result.total_pages,
items=[
market.model.AgentResponse(
id=item.agent.id,
name=item.agent.name,
description=item.agent.description,
author=item.agent.author,
keywords=item.agent.keywords,
categories=item.agent.categories,
version=item.agent.version,
createdAt=item.agent.createdAt,
updatedAt=item.agent.updatedAt,
views=(
item.agent.AnalyticsTracker[0].views
if item.agent.AnalyticsTracker
and len(item.agent.AnalyticsTracker) > 0
else 0
),
downloads=(
item.agent.AnalyticsTracker[0].downloads
if item.agent.AnalyticsTracker
and len(item.agent.AnalyticsTracker) > 0
else 0
),
submissionStatus=item.agent.submissionStatus,
)
for item in result.featured_agents
if item.agent is not None
],
)
return ret
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
raise fastapi.HTTPException(
status_code=500, detail=f"An unexpected error occurred: {e}"
) from e

View File

@@ -1,26 +0,0 @@
import fastapi
import market.db
import market.model
router = fastapi.APIRouter()
@router.post("/agent-installed")
async def agent_installed_endpoint(
event_data: market.model.AgentInstalledFromMarketplaceEventData,
):
"""
Endpoint to track agent installation events from the marketplace.
Args:
event_data (market.model.AgentInstalledFromMarketplaceEventData): The event data.
"""
try:
await market.db.create_agent_installed_event(event_data)
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(
status_code=500, detail=f"An unexpected error occurred: {e}"
)

View File

@@ -1,56 +0,0 @@
import typing
import fastapi
import prisma.enums
import market.db
import market.model
import market.utils.extension_types
router = fastapi.APIRouter()
@router.get("/search")
async def search(
query: str,
page: int = fastapi.Query(1, description="The pagination page to start on"),
page_size: int = fastapi.Query(
10, description="The number of items to return per page"
),
categories: typing.List[str] = fastapi.Query(
None, description="The categories to filter by"
),
description_threshold: int = fastapi.Query(
60, description="The number of characters to return from the description"
),
sort_by: str = fastapi.Query("rank", description="Sorting by column"),
sort_order: typing.Literal["desc", "asc"] = fastapi.Query(
"desc", description="The sort order based on sort_by"
),
submission_status: prisma.enums.SubmissionStatus = fastapi.Query(
prisma.enums.SubmissionStatus.APPROVED,
description="The submission status to filter by",
),
) -> market.model.ListResponse[market.utils.extension_types.AgentsWithRank]:
"""searches endpoint for agents
Args:
query (str): the search query
page (int, optional): the pagination page to start on. Defaults to 1.
page_size (int, optional): the number of items to return per page. Defaults to 10.
category (str | None, optional): the agent category to filter by. None is no filter. Defaults to None.
description_threshold (int, optional): the number of characters to return from the description. Defaults to 60.
sort_by (str, optional): Sorting by column. Defaults to "rank".
sort_order ('asc' | 'desc', optional): the sort order based on sort_by. Defaults to "desc".
"""
agents = await market.db.search_db(
query=query,
page=page,
page_size=page_size,
categories=categories,
description_threshold=description_threshold,
sort_by=sort_by,
sort_order=sort_order,
submission_status=submission_status,
)
return agents

View File

@@ -1,35 +0,0 @@
import autogpt_libs.auth
import fastapi
import fastapi.responses
import prisma
import market.db
import market.model
import market.utils.analytics
router = fastapi.APIRouter()
@router.post("/agents/submit", response_model=market.model.AgentResponse)
async def submit_agent(
request: market.model.AddAgentRequest,
user: autogpt_libs.auth.User = fastapi.Depends(autogpt_libs.auth.requires_user),
):
"""
A basic endpoint to create a new agent entry in the database.
"""
try:
agent = await market.db.create_agent_entry(
request.graph["name"],
request.graph["description"],
request.author,
request.keywords,
request.categories,
prisma.Json(request.graph),
)
return fastapi.responses.PlainTextResponse(agent.model_dump_json())
except market.db.AgentQueryError as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))

View File

@@ -1,47 +0,0 @@
import prisma.models
async def track_download(agent_id: str):
"""
Track the download event in the database.
Args:
agent_id (str): The ID of the agent.
version (int | None, optional): The version of the agent. Defaults to None.
Raises:
Exception: If there is an error tracking the download event.
"""
try:
await prisma.models.AnalyticsTracker.prisma().upsert(
where={"agentId": agent_id},
data={
"update": {"downloads": {"increment": 1}},
"create": {"agentId": agent_id, "downloads": 1, "views": 0},
},
)
except Exception as e:
raise Exception(f"Error tracking download event: {str(e)}")
async def track_view(agent_id: str):
"""
Track the view event in the database.
Args:
agent_id (str): The ID of the agent.
version (int | None, optional): The version of the agent. Defaults to None.
Raises:
Exception: If there is an error tracking the view event.
"""
try:
await prisma.models.AnalyticsTracker.prisma().upsert(
where={"agentId": agent_id},
data={
"update": {"views": {"increment": 1}},
"create": {"agentId": agent_id, "downloads": 0, "views": 1},
},
)
except Exception as e:
raise Exception(f"Error tracking view event: {str(e)}")

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