Compare commits

..

22 Commits

Author SHA1 Message Date
Zamil Majdy
cab12992d3 feat(backend/executor): Avoid full table scan on AgentNodeExecutionInputOutput table 2025-05-27 02:52:48 +07:00
Reinier van der Leer
8e2fb2daa4 feat(backend): Speed up graph create/update (#10025)
- Resolves #10024

Caching the repeated DB calls by the graph lifecycle hooks significantly
speeds up graph update/create calls with many authenticated blocks
(~300ms saved per authenticated block)

### Changes 🏗️

- Add and use `IntegrationCredentialsManager.cached_getter(user_id)` in
lifecycle hooks
- Split `refresh_if_needed(..)` method out of
`IntegrationCredentialsManager.get(..)`
- Simplify interface of lifecycle hooks: change `get_credentials`
parameter to `user_id`

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Save a graph with nodes with credentials
2025-05-26 09:59:27 +00:00
Reinier van der Leer
767d2f2c1e dx(backend): Disable pre-commit pytest hooks (#10003)
Running the tests locally takes a lot of time and leaves test data
behind in the DB, making it impractical to actually run locally.
I'm disabling the `pytest` hooks in the pre-commit config so the
pre-commit checks can reasonably be used without significant negative
impact to DX.

This doesn't impact UX and there is nothing to test.
2025-05-25 12:40:59 +00:00
Reinier van der Leer
45578136e3 feat(frontend): Page-specific titles (#9995)
- Resolves #8656

Instead of "NextGen AutoGPT", make page titles like "My Test Agent -
Library - AutoGPT Platform", "Settings - AutoGPT Platform", "Builder -
AutoGPT Platform".

### Changes 🏗️

- Add specific page titles to `/library`, `/library/agents/[id]`,
`/build`, `/profile`, `/profile/api_keys`
- Fix page titles on `/marketplace`, `/profile/settings`

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Go to `/marketplace` and check the page title
  - [x] Go to `/library` and check the page title
  - [x] Go to `/library/agents/[id]` and check the page title
  - [x] Go to `/build` and check the page title
  - [x] Go to `/profile` and check the page title
  - [x] Go to `/profile/settings` and check the page title
  - [x] Go to `/profile/api_keys` and check the page title
  - [ ] ~~Go to `/profile/dashboard` and check the page title~~
  - [ ] ~~Go to `/profile/integrations` and check the page title~~
  - [ ] ~~Go to `/profile/credits` and check the page title~~
2025-05-25 05:52:51 +00:00
Reinier van der Leer
a51af36296 feat(blocks/exa): Fix Exa blocks error reporting (#10020)
Exa blocks currently just return an empty list when they fail.

## Changes
- Add `error` output field where missing on Exa blocks
- Don't yield empty results when a request fails

## Testing
- `ruff check autogpt_platform/backend/backend/blocks/exa/search.py
autogpt_platform/backend/backend/blocks/exa/contents.py
autogpt_platform/backend/backend/blocks/exa/similar.py --fix`
- `black autogpt_platform/backend/backend/blocks/exa/search.py
autogpt_platform/backend/backend/blocks/exa/contents.py
autogpt_platform/backend/backend/blocks/exa/similar.py`
- `isort autogpt_platform/backend/backend/blocks/exa/search.py
autogpt_platform/backend/backend/blocks/exa/contents.py
autogpt_platform/backend/backend/blocks/exa/similar.py`
- `pre-commit run --files
autogpt_platform/backend/backend/blocks/exa/search.py
autogpt_platform/backend/backend/blocks/exa/contents.py
autogpt_platform/backend/backend/blocks/exa/similar.py` *(fails: redis
connection errors)*
2025-05-24 16:17:03 +00:00
Reinier van der Leer
5518c2e9a2 fix(frontend): Fix global <body> styling and base fonts (#9574)
Base styling currently being fragmented between `layout.tsx` and
`globals.css` is causing some styling (e.g. application background
color) to be incorrectly overridden.

### Changes 🏗️

- Remove background color override from `<body>`
- Move `<body>` classes from `layout.tsx` to `globals.css`
- Remove background color from elements that shouldn't have their own
background color
- Remove `font-neue`, `font-inter`; replace by Geist (`font-sans`) where
necessary

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Effective background color of application is `#FAFAFA` like before
  - [x] Default font is Geist
  - [x] Everything looks okay
2025-05-24 15:25:26 +00:00
Bently
dc981b52a3 feat(Platform): add claude 4 sonnet and opus models to platform (#10018)
This adds the latest claude 4 opus and sonnet to the platform

https://www.anthropic.com/news/claude-4
2025-05-22 19:16:25 +00:00
ograce1421
61643e6a47 fix(frontend): Top Agents header spacing (#10002)
Changed the section header for "Top Agents" to include a 24px margin. 
I have not tested this, an eng needs to test / look at this

## Summary
- set `margin` default to 24px in `AgentsSection`
- apply the bottom margin via an inline style

## Testing
- `npm test` *(fails: playwright not found)*
- `npm run lint` *(fails: next not found)*


### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Test via deployment to the dev branch and verify by designer

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
2025-05-22 16:12:08 +00:00
Reinier van der Leer
21b4d272ce feat(frontend/library): Replace "Loading..." by loading spinners (#9993)
- Resolves #9992

### Changes 🏗️

- Use `<LoadingBox>` instead of "Loading..." on `/library/agents/[id]`

![2025-05-20 23 26
vivaldi](https://github.com/user-attachments/assets/6fe8ce60-c249-4e4c-b3f1-eea925b003d3)


### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] Designer approves based on screencapture
2025-05-22 15:58:32 +00:00
Toran Bruce Richards
b8ba572629 Fix AddMemoryBlock JSON serialization error (#10013)
This pull request refines the handling of `input_data.content` and
improves error message formatting in the `run` method of `mem0.py`. The
changes enhance robustness and clarity in the code.

### Handling `input_data.content`:

* Updated the `run` method to handle `Content` objects explicitly,
ensuring proper formatting of messages when `input_data.content` is of
type `Content`. Additionally, non-standard types are now converted to
strings for consistent handling.
(`[autogpt_platform/backend/backend/blocks/mem0.pyR127-R130](diffhunk://#diff-d7abf8c3299388129480b6a9be78438fe7e0fbe239da630ebb486ad99c80dd24R127-R130)`)

### Error message formatting:

* Simplified the error message formatting by removing the unnecessary
`object=` keyword in the `str()` conversion of exceptions.
(`[autogpt_platform/backend/backend/blocks/mem0.pyL155-R157](diffhunk://#diff-d7abf8c3299388129480b6a9be78438fe7e0fbe239da630ebb486ad99c80dd24L155-R157)`)

## Summary
- fix AddMemoryBlock so `Content` input uses the underlying string
- improve error handling in Mem0 AddMemoryBlock

## Testing
- `ruff check autogpt_platform/backend/backend/blocks/mem0.py`
- `pre-commit run --files
autogpt_platform/backend/backend/blocks/mem0.py` *(fails: unable to
fetch remote hooks)*
- `poetry run pytest -k AddMemoryBlock -q` *(fails: Error 111 connecting
to localhost:6379)*

Checklist 📋
For code changes:
 I have clearly listed my changes in the PR description
 I have made a test plan
 I have tested my changes according to the test plan:
 Payload for webhook-triggered runs is shown on /library/agents/[id]
2025-05-22 15:54:18 +00:00
Nicholas Tindle
47deeb53c3 docs(platform): update AGENTS instructions (#10016)
## Summary
- refine contribution instructions in `autogpt_platform/AGENTS.md`

## Testing
- `pre-commit` *(fails to fetch hooks due to no network access)*

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] Docs only hcnage

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-22 15:54:02 +00:00
Zamil Majdy
1b81a7c755 fix(blocks): Error messages from SendWebRequestBlock use the requested translated IP instead of the orignal URL (#10009)
### Changes 🏗️

Keep the original URL when an HTTP error occurs in
`SendWebRequestBlock`.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Test sending POST request on a web that doesn't support POST
request using `SendWebRequestBlock`.
2025-05-22 15:46:01 +00:00
Zamil Majdy
8f1b3eb8ba fix(backend/executor): Make executor continuously running and retrying message consumption (#9999)
The executor can sometimes become dangling due to the executor stopping
executing messages but the process is not fully killed. This PR avoids
such a scenario by simply keeping retrying it.

### Changes 🏗️

Introduced continuous_retry decorator and use it to executor message
consumption/

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] Run executor service and execute some agents.
2025-05-22 12:11:08 +00:00
Reinier van der Leer
73ee6e272a fix(backend): Unbreak UserIntegrations parsing for missing None values (#9994)
Makes all optional fields on `Credentials` models actually optional, and
sets `exclude_none=True` on the corresponding `model_dump`.

This is a hotfix: after running the `aryshare-revid` branch on the dev
deployment, there is some data in the DB that isn't valid for the
`UserIntegrations` model on the `dev` branch (see
[here](https://github.com/Significant-Gravitas/AutoGPT/pull/9946#discussion_r2098428575)).

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] This fix worked on the `aryshare-revid` branch:
52b6d9696b
2025-05-20 23:50:19 +00:00
Reinier van der Leer
f466b010e4 fix(backend): Unbreak URL handling for GitHub blocks (#9989)
- Resolves #9987

### Changes 🏗️

- Split `pin_url(..)` out of `validate_url(..)` and call
`extra_url_validator` in between

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] GitHub Read Pull Request Block works with "Include PR Changes"
enabled
2025-05-20 22:54:39 +00:00
Nicholas Tindle
f8965e530f ref(frontend/admin): fix location of spending page (#9991)
### Changes 🏗️
Moves the route path for spending
drops min

<!-- Concisely describe all of the changes made in this pull request:
-->

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] test locally

---------

Co-authored-by: Bently <Github@bentlybro.com>
2025-05-20 21:43:38 +00:00
Zamil Majdy
701d283f69 fix(backend): Disable health check for scheduler service from the api server 2025-05-20 18:04:10 +01:00
Zamil Majdy
47c1a64cc2 fix(backend): Remove cleaner on graph executor exit 2025-05-20 17:45:19 +01:00
Reinier van der Leer
cf9cf4e7dd refactor(frontend): Move OttoChatWidget out of root layout (#9951)
- Resolves #9950

### Changes 🏗️

- Move `<OttoChatWidget>` from root layout into `FlowEditor`
- Pass graph info directly into `OttoChatWidget` instead of using
`useAgentGraph`
- Rearrange z-indices of elements in the builder

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - Go to `/build`
  - [x] -> chat widget should show up in the bottom right corner
  - Open the widget and ask Otto something
  - [x] -> should work normally
  - Add a few blocks and save the graph
  - [x] -> "Include graph data" should show up
  - Click "Include graph data" and ask Otto something about your graph
  - [x] -> Otto should be aware of the graph structure and metadata
2025-05-20 13:38:09 +00:00
Reinier van der Leer
0a79e1c5fd feat(frontend/library): Show toast on WebSocket (dis|re)connect (#9949)
- Resolves #9941
- Follow-up to #9935

### Changes 🏗️

- Show toast when WS connection (dis|re)connects (on `/library/agents/[id]`)
  - Implement `BackendAPI.onWebSocketDisconnect`

Related improvements:
- Clean up WebSocket state management & logging in `BackendAPI`
- Clean up & split loading spinner implementation: `Spinner` -> `LoadingBox` + `LoadingSpinner`

Also, unrelated:
- fix(frontend/library): Add 2 second debounce to page refresh logic
  This eliminates 3 triple API calls (so 9 -> 3 total) on page load: `GET /library/agents/{agent_id}`, `GET /graphs/{graph_id}/executions`, and `GET /graphs/{graph_id}/executions/{exec_id}`

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - Start the frontend and backend applications (locally)
  - Navigate to `/library/agents/[id]`
  - Kill the backend
  - [x] -> a toast should appear "Connection to server was lost"
  - [x] -> this toast should be shown as long as the server is down
  - Re-start the backend
  - [x] -> toast should change to show "Connection re-established"
  - [x] -> toast should now disappear after 2 seconds

---

Co-authored-by: Krzysztof Czerwinski <kpczerwinski@gmail.com>
2025-05-19 23:17:06 +02:00
Reinier van der Leer
ac532ca4b9 fix(backend): Graph execution update on terminate (#9952)
Resolves #9947

### Changes 🏗️

Backend:
- Send a graph execution update after terminating a run
- Don't wipe the graph execution stats when not passed in to `update_graph_execution_stats`

Frontend:
- Don't hide the output of stopped runs

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - Go to `/library/agents/[id]`
  - Run an agent that takes a while (long enough to click stop and see the effect)
  - Hit stop after it has executed a few nodes
  - [x] -> run status should change to "Stopped"
  - [x] -> run stats (steps, duration, cost) should stay the same or increase only one last time
  - [x] -> output so far should be visible
  - [x] -> shown information should stay the same after refreshing the page

---

Co-authored-by: Krzysztof Czerwinski <34861343+kcze@users.noreply.github.com>
2025-05-19 23:15:39 +02:00
Zamil Majdy
aa2c2c1ad2 fix(backend): Force process exit on execution manager cleanup 2025-05-19 16:47:56 +01:00
78 changed files with 671 additions and 669 deletions

View File

@@ -241,38 +241,38 @@ repos:
language: system
pass_filenames: false
- repo: local
hooks:
- id: pytest
name: Run tests - AutoGPT Platform - Backend
alias: pytest-platform-backend
entry: bash -c 'cd autogpt_platform/backend && poetry run pytest'
# include autogpt_libs source (since it's a path dependency) but exclude *_test.py files:
files: ^autogpt_platform/(backend/((backend|test)/|poetry\.lock$)|autogpt_libs/(autogpt_libs/.*(?<!_test)\.py|poetry\.lock)$)
language: system
pass_filenames: false
# - repo: local
# hooks:
# - id: pytest
# name: Run tests - AutoGPT Platform - Backend
# alias: pytest-platform-backend
# entry: bash -c 'cd autogpt_platform/backend && poetry run pytest'
# # include autogpt_libs source (since it's a path dependency) but exclude *_test.py files:
# files: ^autogpt_platform/(backend/((backend|test)/|poetry\.lock$)|autogpt_libs/(autogpt_libs/.*(?<!_test)\.py|poetry\.lock)$)
# language: system
# pass_filenames: false
- id: pytest
name: Run tests - Classic - AutoGPT (excl. slow tests)
alias: pytest-classic-autogpt
entry: bash -c 'cd classic/original_autogpt && poetry run pytest --cov=autogpt -m "not slow" tests/unit tests/integration'
# include forge source (since it's a path dependency) but exclude *_test.py files:
files: ^(classic/original_autogpt/((autogpt|tests)/|poetry\.lock$)|classic/forge/(forge/.*(?<!_test)\.py|poetry\.lock)$)
language: system
pass_filenames: false
# - id: pytest
# name: Run tests - Classic - AutoGPT (excl. slow tests)
# alias: pytest-classic-autogpt
# entry: bash -c 'cd classic/original_autogpt && poetry run pytest --cov=autogpt -m "not slow" tests/unit tests/integration'
# # include forge source (since it's a path dependency) but exclude *_test.py files:
# files: ^(classic/original_autogpt/((autogpt|tests)/|poetry\.lock$)|classic/forge/(forge/.*(?<!_test)\.py|poetry\.lock)$)
# language: system
# pass_filenames: false
- id: pytest
name: Run tests - Classic - Forge (excl. slow tests)
alias: pytest-classic-forge
entry: bash -c 'cd classic/forge && poetry run pytest --cov=forge -m "not slow"'
files: ^classic/forge/(forge/|tests/|poetry\.lock$)
language: system
pass_filenames: false
# - id: pytest
# name: Run tests - Classic - Forge (excl. slow tests)
# alias: pytest-classic-forge
# entry: bash -c 'cd classic/forge && poetry run pytest --cov=forge -m "not slow"'
# files: ^classic/forge/(forge/|tests/|poetry\.lock$)
# language: system
# pass_filenames: false
- id: pytest
name: Run tests - Classic - Benchmark
alias: pytest-classic-benchmark
entry: bash -c 'cd classic/benchmark && poetry run pytest --cov=benchmark'
files: ^classic/benchmark/(agbenchmark/|tests/|poetry\.lock$)
language: system
pass_filenames: false
# - id: pytest
# name: Run tests - Classic - Benchmark
# alias: pytest-classic-benchmark
# entry: bash -c 'cd classic/benchmark && poetry run pytest --cov=benchmark'
# files: ^classic/benchmark/(agbenchmark/|tests/|poetry\.lock$)
# language: system
# pass_filenames: false

50
AGENTS.md Normal file
View File

@@ -0,0 +1,50 @@
# AutoGPT Platform Contribution Guide
This guide provides context for Codex when updating the **autogpt_platform** folder.
## Directory overview
- `autogpt_platform/backend` FastAPI based backend service.
- `autogpt_platform/autogpt_libs` Shared Python libraries.
- `autogpt_platform/frontend` Next.js + Typescript frontend.
- `autogpt_platform/docker-compose.yml` development stack.
See `docs/content/platform/getting-started.md` for setup instructions.
## Code style
- Format Python code with `poetry run format`.
- Format frontend code using `yarn format`.
## Testing
- Backend: `poetry run test` (runs pytest with a docker based postgres + prisma).
- Frontend: `yarn test` or `yarn test-ui` for Playwright tests. See `docs/content/platform/contributing/tests.md` for tips.
Always run the relevant linters and tests before committing.
Use conventional commit messages for all commits (e.g. `feat(backend): add API`).
Types:
- feat
- fix
- refactor
- ci
- dx (developer experience)
Scopes:
- platform
- platform/library
- platform/marketplace
- backend
- backend/executor
- frontend
- frontend/library
- frontend/marketplace
- blocks
## Pull requests
- Use the template in `.github/PULL_REQUEST_TEMPLATE.md`.
- Rely on the pre-commit checks for linting and formatting
- Fill out the **Changes** section and the checklist.
- Use conventional commit titles with a scope (e.g. `feat(frontend): add feature`).
- Keep out-of-scope changes under 20% of the PR.
- Ensure PR descriptions are complete.
- For changes touching `data/*.py`, validate user ID checks or explain why not needed.
- If adding protected frontend routes, update `frontend/lib/supabase/middleware.ts`.
- Use the linear ticket branch structure if given codex/open-1668-resume-dropped-runs

View File

@@ -1,3 +1,3 @@
# AutoGPT Libs
This is a new project to store shared functionality across different services in NextGen AutoGPT (e.g. authentication)
This is a new project to store shared functionality across different services in the AutoGPT Platform (e.g. authentication)

View File

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

View File

@@ -78,6 +78,9 @@ class ExaSearchBlock(Block):
description="List of search results",
default_factory=list,
)
error: str = SchemaField(
description="Error message if the request failed",
)
def __init__(self):
super().__init__(
@@ -140,4 +143,3 @@ class ExaSearchBlock(Block):
yield "results", data.get("results", [])
except Exception as e:
yield "error", str(e)
yield "results", []

View File

@@ -67,6 +67,7 @@ class ExaFindSimilarBlock(Block):
description="List of similar documents with title, URL, published date, author, and score",
default_factory=list,
)
error: str = SchemaField(description="Error message if the request failed")
def __init__(self):
super().__init__(
@@ -125,4 +126,3 @@ class ExaFindSimilarBlock(Block):
yield "results", data.get("results", [])
except Exception as e:
yield "error", str(e)
yield "results", []

View File

@@ -1,19 +1,30 @@
from typing import overload
from urllib.parse import urlparse
from backend.blocks.github._auth import (
GithubCredentials,
GithubFineGrainedAPICredentials,
)
from backend.util.request import Requests
from backend.util.request import URL, Requests
def _convert_to_api_url(url: str) -> str:
@overload
def _convert_to_api_url(url: str) -> str: ...
@overload
def _convert_to_api_url(url: URL) -> URL: ...
def _convert_to_api_url(url: str | URL) -> str | URL:
"""
Converts a standard GitHub URL to the corresponding GitHub API URL.
Handles repository URLs, issue URLs, pull request URLs, and more.
"""
parsed_url = urlparse(url)
path_parts = parsed_url.path.strip("/").split("/")
if url_as_str := isinstance(url, str):
url = urlparse(url)
path_parts = url.path.strip("/").split("/")
if len(path_parts) >= 2:
owner, repo = path_parts[0], path_parts[1]
@@ -28,7 +39,7 @@ def _convert_to_api_url(url: str) -> str:
else:
raise ValueError("Invalid GitHub URL format.")
return api_url
return api_url if url_as_str else urlparse(api_url)
def _get_headers(credentials: GithubCredentials) -> dict[str, str]:

View File

@@ -9,8 +9,6 @@ from pydantic import BaseModel
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.settings import Settings
from backend.util.file import MediaFileType, store_media_file, get_exec_file_path
from pathlib import Path
from ._auth import (
GOOGLE_OAUTH_IS_CONFIGURED,
@@ -30,7 +28,6 @@ class Attachment(BaseModel):
class Email(BaseModel):
threadId: str
id: str
subject: str
snippet: str
@@ -85,7 +82,6 @@ class GmailReadBlock(Block):
(
"email",
{
"threadId": "t1",
"id": "1",
"subject": "Test Email",
"snippet": "This is a test email",
@@ -101,7 +97,6 @@ class GmailReadBlock(Block):
"emails",
[
{
"threadId": "t1",
"id": "1",
"subject": "Test Email",
"snippet": "This is a test email",
@@ -118,7 +113,6 @@ class GmailReadBlock(Block):
test_mock={
"_read_emails": lambda *args, **kwargs: [
{
"threadId": "t1",
"id": "1",
"subject": "Test Email",
"snippet": "This is a test email",
@@ -191,7 +185,6 @@ class GmailReadBlock(Block):
attachments = self._get_attachments(service, msg)
email = Email(
threadId=msg["threadId"],
id=msg["id"],
subject=headers.get("subject", "No Subject"),
snippet=msg["snippet"],
@@ -535,180 +528,3 @@ class GmailRemoveLabelBlock(Block):
if label["name"] == label_name:
return label["id"]
return None
class GmailGetThreadBlock(Block):
class Input(BlockSchema):
credentials: GoogleCredentialsInput = GoogleCredentialsField(
["https://www.googleapis.com/auth/gmail.readonly"]
)
threadId: str = SchemaField(description="Gmail thread ID")
includeSpamTrash: bool = SchemaField(
description="Include messages from Spam and Trash", default=False
)
class Output(BlockSchema):
thread: dict = SchemaField(description="Raw Gmail thread resource")
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="21a79166-9df7-4b5f-9f36-96f639d86112",
description="Get a full Gmail thread by ID",
categories={BlockCategory.COMMUNICATION},
input_schema=GmailGetThreadBlock.Input,
output_schema=GmailGetThreadBlock.Output,
disabled=not GOOGLE_OAUTH_IS_CONFIGURED,
test_input={"threadId": "t1", "credentials": TEST_CREDENTIALS_INPUT},
test_credentials=TEST_CREDENTIALS,
test_output=[("thread", {"id": "t1"})],
test_mock={
"_get_thread": lambda *args, **kwargs: {"id": "t1"}
},
)
def run(
self, input_data: Input, *, credentials: GoogleCredentials, **kwargs
) -> BlockOutput:
service = GmailReadBlock._build_service(credentials, **kwargs)
thread = self._get_thread(
service, input_data.threadId, input_data.includeSpamTrash
)
yield "thread", thread
def _get_thread(self, service, thread_id: str, include_spam_trash: bool) -> dict:
return (
service.users()
.threads()
.get(
userId="me",
id=thread_id,
format="full",
includeSpamTrash=include_spam_trash,
)
.execute()
)
class GmailReplyBlock(Block):
class Input(BlockSchema):
credentials: GoogleCredentialsInput = GoogleCredentialsField(
["https://www.googleapis.com/auth/gmail.send"]
)
threadId: str = SchemaField(description="Thread ID to reply in")
parentMessageId: str = SchemaField(
description="ID of the message being replied to"
)
to: list[str] = SchemaField(description="To recipients", default_factory=list)
cc: list[str] = SchemaField(description="CC recipients", default_factory=list)
bcc: list[str] = SchemaField(description="BCC recipients", default_factory=list)
subject: str = SchemaField(description="Email subject", default="")
body: str = SchemaField(description="Email body")
attachments: list[MediaFileType] = SchemaField(
description="Files to attach", default_factory=list, advanced=True
)
class Output(BlockSchema):
messageId: str = SchemaField(description="Sent message ID")
threadId: str = SchemaField(description="Thread ID")
message: dict = SchemaField(description="Raw Gmail message object")
error: str = SchemaField(description="Error message if any")
def __init__(self):
super().__init__(
id="12bf5a24-9b90-4f40-9090-4e86e6995e60",
description="Reply to a Gmail thread",
categories={BlockCategory.COMMUNICATION},
input_schema=GmailReplyBlock.Input,
output_schema=GmailReplyBlock.Output,
disabled=not GOOGLE_OAUTH_IS_CONFIGURED,
test_input={
"threadId": "t1",
"parentMessageId": "m1",
"body": "Thanks",
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("messageId", "m2"),
("threadId", "t1"),
],
test_mock={
"_reply": lambda *args, **kwargs: {
"id": "m2",
"threadId": "t1",
}
},
)
def run(
self, input_data: Input, *, credentials: GoogleCredentials, graph_exec_id: str, **kwargs
) -> BlockOutput:
service = GmailReadBlock._build_service(credentials, **kwargs)
message = self._reply(
service,
input_data,
graph_exec_id,
)
yield "messageId", message["id"]
yield "threadId", message.get("threadId", input_data.threadId)
yield "message", message
def _reply(self, service, input_data: Input, graph_exec_id: str) -> dict:
parent = (
service.users()
.messages()
.get(
userId="me",
id=input_data.parentMessageId,
format="metadata",
metadataHeaders=["Subject", "References", "Message-ID"],
)
.execute()
)
headers = {h["name"].lower(): h["value"] for h in parent.get("payload", {}).get("headers", [])}
subject = input_data.subject or (
f"Re: {headers.get('subject', '')}".strip()
)
references = headers.get("references", "").split()
if headers.get("message-id"):
references.append(headers["message-id"])
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email import encoders
msg = MIMEMultipart()
if input_data.to:
msg["To"] = ", ".join(input_data.to)
if input_data.cc:
msg["Cc"] = ", ".join(input_data.cc)
if input_data.bcc:
msg["Bcc"] = ", ".join(input_data.bcc)
msg["Subject"] = subject
if headers.get("message-id"):
msg["In-Reply-To"] = headers["message-id"]
if references:
msg["References"] = " ".join(references)
msg.attach(MIMEText(input_data.body, "html" if "<" in input_data.body else "plain"))
for attach in input_data.attachments:
local_path = store_media_file(graph_exec_id, attach, return_content=False)
abs_path = get_exec_file_path(graph_exec_id, local_path)
part = MIMEBase("application", "octet-stream")
with open(abs_path, "rb") as f:
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header("Content-Disposition", f"attachment; filename={Path(abs_path).name}")
msg.attach(part)
import base64
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
return (
service.users()
.messages()
.send(userId="me", body={"threadId": input_data.threadId, "raw": raw})
.execute()
)

View File

@@ -101,6 +101,8 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
GPT4_TURBO = "gpt-4-turbo"
GPT3_5_TURBO = "gpt-3.5-turbo"
# Anthropic models
CLAUDE_4_OPUS = "claude-opus-4-20250514"
CLAUDE_4_SONNET = "claude-sonnet-4-20250514"
CLAUDE_3_7_SONNET = "claude-3-7-sonnet-20250219"
CLAUDE_3_5_SONNET = "claude-3-5-sonnet-latest"
CLAUDE_3_5_HAIKU = "claude-3-5-haiku-latest"
@@ -184,6 +186,12 @@ MODEL_METADATA = {
), # gpt-4-turbo-2024-04-09
LlmModel.GPT3_5_TURBO: ModelMetadata("openai", 16385, 4096), # gpt-3.5-turbo-0125
# https://docs.anthropic.com/en/docs/about-claude/models
LlmModel.CLAUDE_4_OPUS: ModelMetadata(
"anthropic", 200000, 8192
), # claude-4-opus-20250514
LlmModel.CLAUDE_4_SONNET: ModelMetadata(
"anthropic", 200000, 8192
), # claude-4-sonnet-20250514
LlmModel.CLAUDE_3_7_SONNET: ModelMetadata(
"anthropic", 200000, 8192
), # claude-3-7-sonnet-20250219

View File

@@ -124,8 +124,10 @@ class AddMemoryBlock(Block, Mem0Base):
if isinstance(input_data.content, Conversation):
messages = input_data.content.messages
elif isinstance(input_data.content, Content):
messages = [{"role": "user", "content": input_data.content.content}]
else:
messages = [{"role": "user", "content": input_data.content}]
messages = [{"role": "user", "content": str(input_data.content)}]
params = {
"user_id": user_id,
@@ -152,7 +154,7 @@ class AddMemoryBlock(Block, Mem0Base):
yield "action", "NO_CHANGE"
except Exception as e:
yield "error", str(object=e)
yield "error", str(e)
class SearchMemoryBlock(Block, Mem0Base):

View File

@@ -47,6 +47,8 @@ MODEL_COST: dict[LlmModel, int] = {
LlmModel.GPT4O: 3,
LlmModel.GPT4_TURBO: 10,
LlmModel.GPT3_5_TURBO: 1,
LlmModel.CLAUDE_4_OPUS: 21,
LlmModel.CLAUDE_4_SONNET: 5,
LlmModel.CLAUDE_3_7_SONNET: 5,
LlmModel.CLAUDE_3_5_SONNET: 4,
LlmModel.CLAUDE_3_5_HAIKU: 1, # $0.80 / $4.00

View File

@@ -24,6 +24,7 @@ from prisma.models import (
)
from prisma.types import (
AgentGraphExecutionCreateInput,
AgentGraphExecutionUpdateManyMutationInput,
AgentGraphExecutionWhereInput,
AgentNodeExecutionCreateInput,
AgentNodeExecutionInputOutputCreateInput,
@@ -491,15 +492,21 @@ async def upsert_execution_input(
"agentNodeId": node_id,
"agentGraphExecutionId": graph_exec_id,
"executionStatus": ExecutionStatus.INCOMPLETE,
"Input": {"every": {"name": {"not": input_name}}},
}
if node_exec_id:
existing_exec_query_filter["id"] = node_exec_id
existing_execution = await AgentNodeExecution.prisma().find_first(
where=existing_exec_query_filter,
order={"addedTime": "asc"},
include={"Input": True},
existing_execution = next(
(
execution
for execution in await AgentNodeExecution.prisma().find_many(
where=existing_exec_query_filter,
order={"addedTime": "asc"},
include={"Input": True},
)
if input_name not in [d.name for d in execution.Input or []]
),
None,
)
json_input_data = Json(input_data)
@@ -572,9 +579,15 @@ async def update_graph_execution_stats(
status: ExecutionStatus,
stats: GraphExecutionStats | None = None,
) -> GraphExecution | None:
data = stats.model_dump() if stats else {}
if isinstance(data.get("error"), Exception):
data["error"] = str(data["error"])
update_data: AgentGraphExecutionUpdateManyMutationInput = {
"executionStatus": status
}
if stats:
stats_dict = stats.model_dump()
if isinstance(stats_dict.get("error"), Exception):
stats_dict["error"] = str(stats_dict["error"])
update_data["stats"] = Json(stats_dict)
updated_count = await AgentGraphExecution.prisma().update_many(
where={
@@ -584,10 +597,7 @@ async def update_graph_execution_stats(
{"executionStatus": ExecutionStatus.QUEUED},
],
},
data={
"executionStatus": status,
"stats": Json(data),
},
data=update_data,
)
if updated_count == 0:
return None

View File

@@ -189,7 +189,7 @@ def SchemaField(
class _BaseCredentials(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
provider: str
title: Optional[str]
title: Optional[str] = None
@field_serializer("*")
def dump_secret_strings(value: Any, _info):
@@ -200,13 +200,13 @@ class _BaseCredentials(BaseModel):
class OAuth2Credentials(_BaseCredentials):
type: Literal["oauth2"] = "oauth2"
username: Optional[str]
username: Optional[str] = None
"""Username of the third-party service user that these credentials belong to"""
access_token: SecretStr
access_token_expires_at: Optional[int]
access_token_expires_at: Optional[int] = None
"""Unix timestamp (seconds) indicating when the access token expires (if at all)"""
refresh_token: Optional[SecretStr]
refresh_token_expires_at: Optional[int]
refresh_token: Optional[SecretStr] = None
refresh_token_expires_at: Optional[int] = None
"""Unix timestamp (seconds) indicating when the refresh token expires (if at all)"""
scopes: list[str]
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -124,7 +124,7 @@ async def get_user_integrations(user_id: str) -> UserIntegrations:
async def update_user_integrations(user_id: str, data: UserIntegrations):
encrypted_data = JSONCryptor().encrypt(data.model_dump())
encrypted_data = JSONCryptor().encrypt(data.model_dump(exclude_none=True))
await User.prisma().update(
where={"id": user_id},
data={"integrations": encrypted_data},

View File

@@ -67,7 +67,7 @@ from backend.util.decorator import error_logged, time_measured
from backend.util.file import clean_exec_files
from backend.util.logging import TruncatedLogger, configure_logging
from backend.util.process import AppProcess, set_service_name
from backend.util.retry import func_retry
from backend.util.retry import continuous_retry, func_retry
from backend.util.service import get_service_client
from backend.util.settings import Settings
@@ -938,9 +938,6 @@ class ExecutionManager(AppProcess):
self.pool_size = settings.config.num_graph_workers
self.running = True
self.active_graph_runs: dict[str, tuple[Future, threading.Event]] = {}
atexit.register(self._on_cleanup)
signal.signal(signal.SIGTERM, lambda sig, frame: self._on_sigterm())
signal.signal(signal.SIGINT, lambda sig, frame: self._on_sigterm())
def run(self):
pool_size_gauge.set(self.pool_size)
@@ -966,22 +963,29 @@ class ExecutionManager(AppProcess):
logger.info(f"[{self.service_name}] ⏳ Connecting to Redis...")
redis.connect()
threading.Thread(
target=lambda: self._consume_execution_cancel(),
daemon=True,
).start()
self._consume_execution_run()
@continuous_retry()
def _consume_execution_cancel(self):
cancel_client = SyncRabbitMQ(create_execution_queue_config())
cancel_client.connect()
cancel_channel = cancel_client.get_channel()
logger.info(f"[{self.service_name}] ⏳ Starting cancel message consumer...")
threading.Thread(
target=lambda: (
cancel_channel.basic_consume(
queue=GRAPH_EXECUTION_CANCEL_QUEUE_NAME,
on_message_callback=self._handle_cancel_message,
auto_ack=True,
),
cancel_channel.start_consuming(),
),
daemon=True,
).start()
cancel_channel.basic_consume(
queue=GRAPH_EXECUTION_CANCEL_QUEUE_NAME,
on_message_callback=self._handle_cancel_message,
auto_ack=True,
)
cancel_channel.start_consuming()
raise RuntimeError(f"❌ cancel message consumer is stopped: {cancel_channel}")
@continuous_retry()
def _consume_execution_run(self):
run_client = SyncRabbitMQ(create_execution_queue_config())
run_client.connect()
run_channel = run_client.get_channel()
@@ -993,6 +997,7 @@ class ExecutionManager(AppProcess):
)
logger.info(f"[{self.service_name}] ⏳ Starting to consume run messages...")
run_channel.start_consuming()
raise RuntimeError(f"❌ run message consumer is stopped: {run_channel}")
def _handle_cancel_message(
self,
@@ -1091,10 +1096,6 @@ class ExecutionManager(AppProcess):
super().cleanup()
self._on_cleanup()
def _on_sigterm(self):
llprint(f"[{self.service_name}] ⚠️ GraphExec SIGTERM received")
self._on_cleanup(log=llprint)
def _on_cleanup(self, log=logger.info):
prefix = f"[{self.service_name}][on_graph_executor_stop {os.getpid()}]"
log(f"{prefix} ⏳ Shutting down service loop...")
@@ -1111,6 +1112,7 @@ class ExecutionManager(AppProcess):
redis.disconnect()
log(f"{prefix} ✅ Finished GraphExec cleanup")
sys.exit(0)
# ------- UTILITIES ------- #

View File

@@ -1,13 +1,13 @@
import logging
from contextlib import contextmanager
from datetime import datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
from autogpt_libs.utils.synchronize import RedisKeyedMutex
from redis.lock import Lock as RedisLock
from backend.data import redis
from backend.data.model import Credentials
from backend.data.model import Credentials, OAuth2Credentials
from backend.integrations.credentials_store import IntegrationCredentialsStore
from backend.integrations.oauth import HANDLERS_BY_NAME
from backend.integrations.providers import ProviderName
@@ -78,25 +78,7 @@ class IntegrationCredentialsManager:
f"{datetime.fromtimestamp(credentials.access_token_expires_at)}; "
f"current time is {datetime.now()}"
)
with self._locked(user_id, credentials_id, "refresh"):
oauth_handler = _get_provider_oauth_handler(credentials.provider)
if oauth_handler.needs_refresh(credentials):
logger.debug(
f"Refreshing '{credentials.provider}' "
f"credentials #{credentials.id}"
)
_lock = None
if lock:
# Wait until the credentials are no longer in use anywhere
_lock = self._acquire_lock(user_id, credentials_id)
fresh_credentials = oauth_handler.refresh_tokens(credentials)
self.store.update_creds(user_id, fresh_credentials)
if _lock and _lock.locked() and _lock.owned():
_lock.release()
credentials = fresh_credentials
credentials = self.refresh_if_needed(user_id, credentials, lock)
else:
logger.debug(f"Credentials #{credentials.id} never expire")
@@ -121,6 +103,50 @@ class IntegrationCredentialsManager:
)
return credentials, lock
def cached_getter(self, user_id: str) -> Callable[[str], "Credentials | None"]:
all_credentials = None
def get_credentials(creds_id: str) -> "Credentials | None":
nonlocal all_credentials
if not all_credentials:
# Fetch credentials on first necessity
all_credentials = self.store.get_all_creds(user_id)
credential = next((c for c in all_credentials if c.id == creds_id), None)
if not credential:
return None
if credential.type != "oauth2" or not credential.access_token_expires_at:
# Credential doesn't expire
return credential
# Credential is OAuth2 credential and has expiration timestamp
return self.refresh_if_needed(user_id, credential)
return get_credentials
def refresh_if_needed(
self, user_id: str, credentials: OAuth2Credentials, lock: bool = True
) -> OAuth2Credentials:
with self._locked(user_id, credentials.id, "refresh"):
oauth_handler = _get_provider_oauth_handler(credentials.provider)
if oauth_handler.needs_refresh(credentials):
logger.debug(
f"Refreshing '{credentials.provider}' "
f"credentials #{credentials.id}"
)
_lock = None
if lock:
# Wait until the credentials are no longer in use anywhere
_lock = self._acquire_lock(user_id, credentials.id)
fresh_credentials = oauth_handler.refresh_tokens(credentials)
self.store.update_creds(user_id, fresh_credentials)
if _lock and _lock.locked() and _lock.owned():
_lock.release()
credentials = fresh_credentials
return credentials
def update(self, user_id: str, updated: Credentials) -> None:
with self._locked(user_id, updated.id):
self.store.update_creds(user_id, updated)

View File

@@ -1,8 +1,9 @@
import logging
from typing import TYPE_CHECKING, Callable, Optional, cast
from typing import TYPE_CHECKING, Optional, cast
from backend.data.block import BlockSchema, BlockWebhookConfig
from backend.data.graph import set_node_webhook
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.webhooks import get_webhook_manager, supports_webhooks
if TYPE_CHECKING:
@@ -12,21 +13,17 @@ if TYPE_CHECKING:
from ._base import BaseWebhooksManager
logger = logging.getLogger(__name__)
credentials_manager = IntegrationCredentialsManager()
async def on_graph_activate(
graph: "GraphModel", get_credentials: Callable[[str], "Credentials | None"]
):
async def on_graph_activate(graph: "GraphModel", user_id: str):
"""
Hook to be called when a graph is activated/created.
⚠️ Assuming node entities are not re-used between graph versions, ⚠️
this hook calls `on_node_activate` on all nodes in this graph.
Params:
get_credentials: `credentials_id` -> Credentials
"""
# Compare nodes in new_graph_version with previous_graph_version
get_credentials = credentials_manager.cached_getter(user_id)
updated_nodes = []
for new_node in graph.nodes:
block_input_schema = cast(BlockSchema, new_node.block.input_schema)
@@ -56,18 +53,14 @@ async def on_graph_activate(
return graph
async def on_graph_deactivate(
graph: "GraphModel", get_credentials: Callable[[str], "Credentials | None"]
):
async def on_graph_deactivate(graph: "GraphModel", user_id: str):
"""
Hook to be called when a graph is deactivated/deleted.
⚠️ Assuming node entities are not re-used between graph versions, ⚠️
this hook calls `on_node_deactivate` on all nodes in `graph`.
Params:
get_credentials: `credentials_id` -> Credentials
"""
get_credentials = credentials_manager.cached_getter(user_id)
updated_nodes = []
for node in graph.nodes:
block_input_schema = cast(BlockSchema, node.block.input_schema)

View File

@@ -2,7 +2,7 @@ import asyncio
import logging
from collections import defaultdict
from datetime import datetime
from typing import TYPE_CHECKING, Annotated, Any, Sequence
from typing import Annotated, Any, Sequence
import pydantic
import stripe
@@ -60,7 +60,6 @@ from backend.data.user import (
from backend.executor import scheduler
from backend.executor import utils as execution_utils
from backend.executor.utils import create_execution_queue_config
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.webhooks.graph_lifecycle_hooks import (
on_graph_activate,
on_graph_deactivate,
@@ -78,13 +77,10 @@ from backend.server.utils import get_user_id
from backend.util.service import get_service_client
from backend.util.settings import Settings
if TYPE_CHECKING:
from backend.data.model import Credentials
@thread_cached
def execution_scheduler_client() -> scheduler.SchedulerClient:
return get_service_client(scheduler.SchedulerClient)
return get_service_client(scheduler.SchedulerClient, health_check=False)
@thread_cached
@@ -101,7 +97,6 @@ def execution_event_bus() -> AsyncRedisExecutionEventBus:
settings = Settings()
logger = logging.getLogger(__name__)
integration_creds_manager = IntegrationCredentialsManager()
_user_credit_model = get_user_credit_model()
@@ -466,10 +461,7 @@ async def create_new_graph(
library_db.add_generated_agent_image(graph, library_agent.id)
)
graph = await on_graph_activate(
graph,
get_credentials=lambda id: integration_creds_manager.get(user_id, id),
)
graph = await on_graph_activate(graph, user_id=user_id)
return graph
@@ -480,11 +472,7 @@ async def delete_graph(
graph_id: str, user_id: Annotated[str, Depends(get_user_id)]
) -> DeleteGraphResponse:
if active_version := await graph_db.get_graph(graph_id, user_id=user_id):
def get_credentials(credentials_id: str) -> "Credentials | None":
return integration_creds_manager.get(user_id, credentials_id)
await on_graph_deactivate(active_version, get_credentials)
await on_graph_deactivate(active_version, user_id=user_id)
return {"version_counts": await graph_db.delete_graph(graph_id, user_id=user_id)}
@@ -521,24 +509,15 @@ async def update_graph(
user_id, graph.id, graph.version
)
def get_credentials(credentials_id: str) -> "Credentials | None":
return integration_creds_manager.get(user_id, credentials_id)
# Handle activation of the new graph first to ensure continuity
new_graph_version = await on_graph_activate(
new_graph_version,
get_credentials=get_credentials,
)
new_graph_version = await on_graph_activate(new_graph_version, user_id=user_id)
# Ensure new version is the only active version
await graph_db.set_graph_active_version(
graph_id=graph_id, version=new_graph_version.version, user_id=user_id
)
if current_active_version:
# Handle deactivation of the previously active version
await on_graph_deactivate(
current_active_version,
get_credentials=get_credentials,
)
await on_graph_deactivate(current_active_version, user_id=user_id)
return new_graph_version
@@ -562,14 +541,8 @@ async def set_graph_active_version(
current_active_graph = await graph_db.get_graph(graph_id, user_id=user_id)
def get_credentials(credentials_id: str) -> "Credentials | None":
return integration_creds_manager.get(user_id, credentials_id)
# Handle activation of the new graph first to ensure continuity
await on_graph_activate(
new_active_graph,
get_credentials=get_credentials,
)
await on_graph_activate(new_active_graph, user_id=user_id)
# Ensure new version is the only active version
await graph_db.set_graph_active_version(
graph_id=graph_id,
@@ -584,10 +557,7 @@ async def set_graph_active_version(
if current_active_graph and current_active_graph.version != new_active_version:
# Handle deactivation of the previously active version
await on_graph_deactivate(
current_active_graph,
get_credentials=get_credentials,
)
await on_graph_deactivate(current_active_graph, user_id=user_id)
@v1_router.post(
@@ -660,11 +630,15 @@ async def _cancel_execution(graph_exec_id: str):
exchange=execution_utils.GRAPH_EXECUTION_CANCEL_EXCHANGE,
)
# Update the status of the graph & node executions
await execution_db.update_graph_execution_stats(
# Update the status of the graph execution
graph_execution = await execution_db.update_graph_execution_stats(
graph_exec_id,
execution_db.ExecutionStatus.TERMINATED,
)
if graph_execution:
await execution_event_bus().publish(graph_execution)
# Update the status of the node executions
node_execs = [
node_exec.model_copy(update={"status": execution_db.ExecutionStatus.TERMINATED})
for node_exec in await execution_db.get_node_executions(
@@ -676,7 +650,6 @@ async def _cancel_execution(graph_exec_id: str):
],
)
]
await execution_db.update_node_execution_status_batch(
[node_exec.node_exec_id for node_exec in node_execs],
execution_db.ExecutionStatus.TERMINATED,

View File

@@ -736,10 +736,7 @@ async def fork_library_agent(library_agent_id: str, user_id: str):
new_graph = await graph_db.fork_graph(
original_agent.graph_id, original_agent.graph_version, user_id
)
new_graph = await on_graph_activate(
new_graph,
get_credentials=lambda id: integration_creds_manager.get(user_id, id),
)
new_graph = await on_graph_activate(new_graph, user_id=user_id)
# Create a library agent for the new graph
return await create_library_agent(new_graph, user_id)

View File

@@ -2,8 +2,9 @@ import ipaddress
import re
import socket
import ssl
from typing import Callable
from urllib.parse import quote, urljoin, urlparse, urlunparse
from typing import Callable, Optional
from urllib.parse import ParseResult as URL
from urllib.parse import quote, urljoin, urlparse
import idna
import requests as req
@@ -44,17 +45,15 @@ def _is_ip_blocked(ip: str) -> bool:
return any(ip_addr in network for network in BLOCKED_IP_NETWORKS)
def _remove_insecure_headers(headers: dict, old_url: str, new_url: str) -> dict:
def _remove_insecure_headers(headers: dict, old_url: URL, new_url: URL) -> dict:
"""
Removes sensitive headers (Authorization, Proxy-Authorization, Cookie)
if the scheme/host/port of new_url differ from old_url.
"""
old_parsed = urlparse(old_url)
new_parsed = urlparse(new_url)
if (
(old_parsed.scheme != new_parsed.scheme)
or (old_parsed.hostname != new_parsed.hostname)
or (old_parsed.port != new_parsed.port)
(old_url.scheme != new_url.scheme)
or (old_url.hostname != new_url.hostname)
or (old_url.port != new_url.port)
):
headers.pop("Authorization", None)
headers.pop("Proxy-Authorization", None)
@@ -81,19 +80,16 @@ class HostSSLAdapter(HTTPAdapter):
)
def validate_url(
url: str,
trusted_origins: list[str],
enable_dns_rebinding: bool = True,
) -> tuple[str, str]:
def validate_url(url: str, trusted_origins: list[str]) -> tuple[URL, bool, list[str]]:
"""
Validates the URL to prevent SSRF attacks by ensuring it does not point
to a private, link-local, or otherwise blocked IP address — unless
the hostname is explicitly trusted.
Returns a tuple of:
- pinned_url: a URL that has the netloc replaced with the validated IP
- ascii_hostname: the original ASCII hostname (IDNA-decoded) for use in the Host header
Returns:
str: The validated, canonicalized, parsed URL
is_trusted: Boolean indicating if the hostname is in trusted_origins
ip_addresses: List of IP addresses for the host; empty if the host is trusted
"""
# Canonicalize URL
url = url.strip("/ ").replace("\\", "/")
@@ -122,45 +118,56 @@ def validate_url(
if not HOSTNAME_REGEX.match(ascii_hostname):
raise ValueError("Hostname contains invalid characters.")
# If hostname is trusted, skip IP-based checks but still return pinned URL
if ascii_hostname in trusted_origins:
pinned_netloc = ascii_hostname
if parsed.port:
pinned_netloc += f":{parsed.port}"
# Check if hostname is trusted
is_trusted = ascii_hostname in trusted_origins
pinned_url = urlunparse(
(
parsed.scheme,
pinned_netloc,
quote(parsed.path, safe="/%:@"),
parsed.params,
parsed.query,
parsed.fragment,
)
)
return pinned_url, ascii_hostname
# If not trusted, validate IP addresses
ip_addresses: list[str] = []
if not is_trusted:
# Resolve all IP addresses for the hostname
ip_addresses = _resolve_host(ascii_hostname)
# Resolve all IP addresses for the hostname
try:
ip_list = [str(res[4][0]) for res in socket.getaddrinfo(ascii_hostname, None)]
ipv4 = [ip for ip in ip_list if ":" not in ip]
ipv6 = [ip for ip in ip_list if ":" in ip]
ip_addresses = ipv4 + ipv6 # Prefer IPv4 over IPv6
except socket.gaierror:
raise ValueError(f"Unable to resolve IP address for hostname {ascii_hostname}")
# Block any IP address that belongs to a blocked range
for ip_str in ip_addresses:
if _is_ip_blocked(ip_str):
raise ValueError(
f"Access to blocked or private IP address {ip_str} "
f"for hostname {ascii_hostname} is not allowed."
)
return (
URL(
parsed.scheme,
ascii_hostname,
quote(parsed.path, safe="/%:@"),
parsed.params,
parsed.query,
parsed.fragment,
),
is_trusted,
ip_addresses,
)
def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL:
"""
Pins a URL to a specific IP address to prevent DNS rebinding attacks.
Args:
url: The original URL
ip_addresses: List of IP addresses corresponding to the URL's host
Returns:
pinned_url: The URL with hostname replaced with IP address
"""
if not url.hostname:
raise ValueError(f"URL has no hostname: {url}")
if not ip_addresses:
raise ValueError(f"No IP addresses found for {ascii_hostname}")
# Resolve all IP addresses for the hostname
ip_addresses = _resolve_host(url.hostname)
# Block any IP address that belongs to a blocked range
for ip_str in ip_addresses:
if _is_ip_blocked(ip_str):
raise ValueError(
f"Access to blocked or private IP address {ip_str} "
f"for hostname {ascii_hostname} is not allowed."
)
# Pin to the first valid IP (for SSRF defense).
# Pin to the first valid IP (for SSRF defense)
pinned_ip = ip_addresses[0]
# If it's IPv6, bracket it
@@ -169,24 +176,31 @@ def validate_url(
else:
pinned_netloc = pinned_ip
if parsed.port:
pinned_netloc += f":{parsed.port}"
if url.port:
pinned_netloc += f":{url.port}"
if not enable_dns_rebinding:
pinned_netloc = ascii_hostname
pinned_url = urlunparse(
(
parsed.scheme,
pinned_netloc,
quote(parsed.path, safe="/%:@"),
parsed.params,
parsed.query,
parsed.fragment,
)
return URL(
url.scheme,
pinned_netloc,
url.path,
url.params,
url.query,
url.fragment,
)
return pinned_url, ascii_hostname # (pinned_url, original_hostname)
def _resolve_host(hostname: str) -> list[str]:
try:
ip_list = [str(res[4][0]) for res in socket.getaddrinfo(hostname, None)]
ipv4 = [ip for ip in ip_list if ":" not in ip]
ipv6 = [ip for ip in ip_list if ":" in ip]
ip_addresses = ipv4 + ipv6 # Prefer IPv4 over IPv6
except socket.gaierror:
raise ValueError(f"Unable to resolve IP address for hostname {hostname}")
if not ip_addresses:
raise ValueError(f"No IP addresses found for {hostname}")
return ip_addresses
class Requests:
@@ -200,7 +214,7 @@ class Requests:
self,
trusted_origins: list[str] | None = None,
raise_for_status: bool = True,
extra_url_validator: Callable[[str], str] | None = None,
extra_url_validator: Callable[[URL], URL] | None = None,
extra_headers: dict[str, str] | None = None,
):
self.trusted_origins = []
@@ -224,12 +238,18 @@ class Requests:
*args,
**kwargs,
) -> req.Response:
# Validate URL and get trust status
url, is_trusted, ip_addresses = validate_url(url, self.trusted_origins)
# Apply any extra user-defined validation/transformation
if self.extra_url_validator is not None:
url = self.extra_url_validator(url)
# Validate URL and get pinned URL + hostname
pinned_url, hostname = validate_url(url, self.trusted_origins)
# Pin the URL if untrusted
hostname = url.hostname
original_url = url.geturl()
if not is_trusted:
url = pin_url(url, ip_addresses)
# Merge any extra headers
headers = dict(headers) if headers else {}
@@ -240,27 +260,30 @@ class Requests:
# If untrusted, the hostname in the URL is replaced with the corresponding
# IP address, and we need to override the Host header with the actual hostname.
if (pinned := urlparse(pinned_url)).hostname != hostname:
if url.hostname != hostname:
headers["Host"] = hostname
# If hostname was untrusted and we replaced it by (pinned it to) its IP,
# we also need to attach a custom SNI adapter to make SSL work:
mount_prefix = f"{pinned.scheme}://{pinned.hostname}"
if pinned.port:
mount_prefix += f":{pinned.port}"
adapter = HostSSLAdapter(ssl_hostname=hostname)
session.mount("https://", adapter)
# Perform the request with redirects disabled for manual handling
response = session.request(
method,
pinned_url,
url.geturl(),
headers=headers,
allow_redirects=False,
*args,
**kwargs,
)
# Replace response URLs with the original host for clearer error messages
if url.hostname != hostname:
response.url = original_url
if response.request is not None:
response.request.url = original_url
if self.raise_for_status:
response.raise_for_status()
@@ -275,13 +298,13 @@ class Requests:
# The base URL is the pinned_url we just used
# so that relative redirects resolve correctly.
new_url = urljoin(pinned_url, location)
redirect_url = urlparse(urljoin(url.geturl(), location))
# Carry forward the same headers but update Host
new_headers = _remove_insecure_headers(dict(headers), url, new_url)
new_headers = _remove_insecure_headers(headers, url, redirect_url)
return self.request(
method,
new_url,
redirect_url.geturl(),
headers=new_headers,
allow_redirects=allow_redirects,
max_redirects=max_redirects - 1,

View File

@@ -2,6 +2,7 @@ import asyncio
import logging
import os
import threading
import time
from functools import wraps
from uuid import uuid4
@@ -80,3 +81,24 @@ func_retry = retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=1, max=30),
)
def continuous_retry(*, retry_delay: float = 1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
while True:
try:
return func(*args, **kwargs)
except Exception as exc:
logger.exception(
"%s failed with %s — retrying in %.2f s",
func.__name__,
exc,
retry_delay,
)
time.sleep(retry_delay)
return wrapper
return decorator

View File

@@ -1,10 +1,10 @@
import pytest
from backend.util.request import validate_url
from backend.util.request import pin_url, validate_url
@pytest.mark.parametrize(
"url, trusted_origins, expected_value, should_raise",
"raw_url, trusted_origins, expected_value, should_raise",
[
# Rejected IP ranges
("localhost", [], None, True),
@@ -55,14 +55,14 @@ from backend.util.request import validate_url
],
)
def test_validate_url_no_dns_rebinding(
url, trusted_origins, expected_value, should_raise
raw_url: str, trusted_origins: list[str], expected_value: str, should_raise: bool
):
if should_raise:
with pytest.raises(ValueError):
validate_url(url, trusted_origins, enable_dns_rebinding=False)
validate_url(raw_url, trusted_origins)
else:
url, host = validate_url(url, trusted_origins, enable_dns_rebinding=False)
assert url == expected_value
validated_url, _, _ = validate_url(raw_url, trusted_origins)
assert validated_url.geturl() == expected_value
@pytest.mark.parametrize(
@@ -79,7 +79,11 @@ def test_validate_url_no_dns_rebinding(
],
)
def test_dns_rebinding_fix(
monkeypatch, hostname, resolved_ips, expect_error, expected_ip
monkeypatch,
hostname: str,
resolved_ips: list[str],
expect_error: bool,
expected_ip: str,
):
"""
Tests that validate_url pins the first valid public IP address, and rejects
@@ -96,11 +100,13 @@ def test_dns_rebinding_fix(
if expect_error:
# If any IP is blocked, we expect a ValueError
with pytest.raises(ValueError):
validate_url(hostname, [])
url, _, ip_addresses = validate_url(hostname, [])
pin_url(url, ip_addresses)
else:
pinned_url, ascii_hostname = validate_url(hostname, [])
url, _, ip_addresses = validate_url(hostname, [])
pinned_url = pin_url(url, ip_addresses).geturl()
# The pinned_url should contain the first valid IP
assert pinned_url.startswith("http://") or pinned_url.startswith("https://")
assert expected_ip in pinned_url
# The ascii_hostname should match our original hostname after IDNA encoding
assert ascii_hostname == hostname
# The unpinned URL's hostname should match our original IDNA encoded hostname
assert url.hostname == hostname

View File

@@ -6,7 +6,7 @@ import FlowEditor from "@/components/Flow";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { useEffect } from "react";
export default function Home() {
export default function BuilderPage() {
const query = useSearchParams();
const { completeStep } = useOnboarding();

View File

@@ -39,9 +39,11 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import LoadingBox, { LoadingSpinner } from "@/components/ui/loading";
export default function AgentRunsPage(): React.ReactElement {
const { id: agentID }: { id: LibraryAgentID } = useParams();
const { toast } = useToast();
const router = useRouter();
const api = useBackendAPI();
@@ -69,7 +71,13 @@ export default function AgentRunsPage(): React.ReactElement {
const { state: onboardingState, updateState: updateOnboardingState } =
useOnboarding();
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
const { toast } = useToast();
// Set page title with agent name
useEffect(() => {
if (agent) {
document.title = `${agent.name} - Library - AutoGPT Platform`;
}
}, [agent]);
const openRunDraftView = useCallback(() => {
selectView({ type: "run" });
@@ -120,7 +128,11 @@ export default function AgentRunsPage(): React.ReactElement {
}
}, [selectedRun, onboardingState, updateOnboardingState]);
const lastRefresh = useRef<number>(0);
const refreshPageData = useCallback(() => {
if (Date.now() - lastRefresh.current < 2e3) return; // 2 second debounce
lastRefresh.current = Date.now();
api.getLibraryAgent(agentID).then((agent) => {
setAgent(agent);
@@ -156,6 +168,44 @@ export default function AgentRunsPage(): React.ReactElement {
// Initial load
useEffect(() => {
refreshPageData();
// Show a toast when the WebSocket connection disconnects
let connectionToast: ReturnType<typeof toast> | null = null;
const cancelDisconnectHandler = api.onWebSocketDisconnect(() => {
connectionToast ??= toast({
title: "Connection to server was lost",
variant: "destructive",
description: (
<div className="flex items-center">
Trying to reconnect...
<LoadingSpinner className="ml-1.5 size-3.5" />
</div>
),
duration: Infinity, // show until connection is re-established
dismissable: false,
});
});
const cancelConnectHandler = api.onWebSocketConnect(() => {
if (connectionToast)
connectionToast.update({
id: connectionToast.id,
title: "✅ Connection re-established",
variant: "default",
description: (
<div className="flex items-center">
Refreshing data...
<LoadingSpinner className="ml-1.5 size-3.5" />
</div>
),
duration: 2000,
dismissable: true,
});
connectionToast = null;
});
return () => {
cancelDisconnectHandler();
cancelConnectHandler();
};
}, []);
// Subscribe to WebSocket updates for agent runs
@@ -314,8 +364,7 @@ export default function AgentRunsPage(): React.ReactElement {
);
if (!agent || !graph) {
/* TODO: implement loading indicators / skeleton page */
return <span>Loading...</span>;
return <LoadingBox className="h-[90vh]" />;
}
return (
@@ -373,7 +422,7 @@ export default function AgentRunsPage(): React.ReactElement {
agentActions={agentActions}
/>
)
) : null) || <p>Loading...</p>}
) : null) || <LoadingBox className="h-[70vh]" />}
<DeleteConfirmDialog
entityType="agent"

View File

@@ -1,4 +1,5 @@
import Link from "next/link";
import { Metadata } from "next/types";
import {
ArrowBottomRightIcon,
@@ -11,11 +12,15 @@ import LibraryActionSubHeader from "@/components/library/library-action-sub-head
import LibraryActionHeader from "@/components/library/library-action-header";
import LibraryAgentList from "@/components/library/library-agent-list";
export const metadata: Metadata = {
title: "Library - AutoGPT Platform",
description: "Your collection of Agents on the AutoGPT Platform",
};
/**
* LibraryPage Component
* Main component that manages the library interface including agent listing and actions
*/
export default function LibraryPage() {
return (
<main className="container min-h-screen space-y-4 pb-20 sm:px-8 md:px-12">

View File

@@ -16,7 +16,7 @@ import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
import {
AuthCard,
AuthHeader,
@@ -98,7 +98,7 @@ export default function LoginPage() {
}
if (isUserLoading || user) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -1,8 +1,4 @@
import BackendAPI from "@/lib/autogpt-server-api";
import {
CreatorDetails as Creator,
StoreAgent,
} from "@/lib/autogpt-server-api";
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
import { Metadata } from "next";
@@ -65,11 +61,11 @@ export default async function Page({
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-4 sm:gap-6 md:gap-8">
<p className="font-geist text-underline-position-from-font text-decoration-skip-none text-left text-base font-medium leading-6">
<p className="text-underline-position-from-font text-decoration-skip-none text-left font-poppins text-base font-medium leading-6">
About
</p>
<div
className="font-poppins text-[48px] font-normal leading-[59px] text-neutral-900 dark:text-zinc-50"
className="text-[48px] font-normal leading-[59px] text-neutral-900 dark:text-zinc-50"
style={{ whiteSpace: "pre-line" }}
>
{creator.description}
@@ -92,9 +88,7 @@ export default async function Page({
} catch (error) {
return (
<div className="flex h-screen w-full items-center justify-center">
<div className="font-neue text-2xl text-neutral-900">
Creator not found
</div>
<div className="text-2xl text-neutral-900">Creator not found</div>
</div>
);
}

View File

@@ -102,9 +102,9 @@ async function getStoreData() {
// FIX: Correct metadata
export const metadata: Metadata = {
title: "Marketplace - NextGen AutoGPT",
title: "Marketplace - AutoGPT Platform",
description: "Find and use AI Agents created by our community",
applicationName: "NextGen AutoGPT Store",
applicationName: "AutoGPT Marketplace",
authors: [{ name: "AutoGPT Team" }],
keywords: [
"AI agents",
@@ -118,22 +118,22 @@ export const metadata: Metadata = {
follow: true,
},
openGraph: {
title: "Marketplace - NextGen AutoGPT",
title: "Marketplace - AutoGPT Platform",
description: "Find and use AI Agents created by our community",
type: "website",
siteName: "NextGen AutoGPT Store",
siteName: "AutoGPT Marketplace",
images: [
{
url: "/images/store-og.png",
width: 1200,
height: 630,
alt: "NextGen AutoGPT Store",
alt: "AutoGPT Marketplace",
},
],
},
twitter: {
card: "summary_large_image",
title: "Marketplace - NextGen AutoGPT",
title: "Marketplace - AutoGPT Platform",
description: "Find and use AI Agents created by our community",
images: ["/images/store-twitter.png"],
},

View File

@@ -120,7 +120,7 @@ function SearchResults({
<div className="mx-auto min-h-screen max-w-[1440px] px-10 lg:min-w-[1440px]">
<div className="mt-8 flex items-center">
<div className="flex-1">
<h2 className="font-geist text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<h2 className="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Results for:
</h2>
<h1 className="font-poppins text-2xl font-semibold leading-[32px] text-neutral-800 dark:text-neutral-100">

View File

@@ -1,5 +1,8 @@
import { Metadata } from "next/types";
import { APIKeysSection } from "@/components/agptui/composite/APIKeySection";
export const metadata: Metadata = { title: "API Keys - AutoGPT Platform" };
const ApiKeysPage = () => {
return (
<div className="w-full pr-4 pt-24 md:pt-0">

View File

@@ -1,5 +1,5 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import useCredits from "@/hooks/useCredits";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";

View File

@@ -27,7 +27,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
export default function PrivatePage() {
const { supabase, user, isUserLoading } = useSupabase();
@@ -123,7 +123,7 @@ export default function PrivatePage() {
);
if (isUserLoading) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!user || !supabase) {

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { Metadata } from "next/types";
import { ProfileInfoForm } from "@/components/agptui/ProfileInfoForm";
import BackendAPI from "@/lib/autogpt-server-api";
import { CreatorDetails } from "@/lib/autogpt-server-api/types";
@@ -17,6 +18,8 @@ async function getProfileData(api: BackendAPI) {
}
}
export const metadata: Metadata = { title: "Profile - AutoGPT Platform" };
export default async function Page({}: {}) {
const api = new BackendAPI();
const { profile } = await getProfileData(api);

View File

@@ -4,8 +4,9 @@ import SettingsForm from "@/components/profile/settings/SettingsForm";
import getServerUser from "@/lib/supabase/getServerUser";
import { redirect } from "next/navigation";
import { getUserPreferences } from "./actions";
export const metadata: Metadata = {
title: "Settings",
title: "Settings - AutoGPT Platform",
description: "Manage your account settings and preferences.",
};

View File

@@ -24,7 +24,7 @@ import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { changePassword, sendResetEmail } from "./actions";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
import { getBehaveAs } from "@/lib/utils";
import { useTurnstile } from "@/hooks/useTurnstile";
@@ -134,7 +134,7 @@ export default function ResetPasswordPage() {
);
if (isUserLoading) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -18,7 +18,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
import {
AuthCard,
AuthHeader,
@@ -94,7 +94,7 @@ export default function SignupPage() {
}
if (isUserLoading || user) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -4,7 +4,7 @@
@layer base {
:root {
--background: 0 0% 99.6%; /* #FEFEFE */
--background: 0 0% 98%; /* neutral-50#FAFAFA */
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
@@ -62,11 +62,7 @@
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
.font-neue {
font-family: "PP Neue Montreal TT", sans-serif;
@apply bg-background font-sans text-foreground antialiased transition-colors;
}
}

View File

@@ -1,20 +1,16 @@
import React, { Suspense } from "react";
import type { Metadata } from "next";
import { Inter, Poppins } from "next/font/google";
import { Poppins } from "next/font/google";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import { cn } from "@/lib/utils";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
import { Providers } from "@/app/providers";
import TallyPopupSimple from "@/components/TallyPopup";
import OttoChatWidget from "@/components/OttoChatWidget";
import { GoogleAnalytics } from "@/components/analytics/google-analytics";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const poppins = Poppins({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
@@ -22,7 +18,7 @@ const poppins = Poppins({
});
export const metadata: Metadata = {
title: "NextGen AutoGPT",
title: "AutoGPT Platform",
description: "Your one stop shop to creating AI Agents",
};
@@ -34,19 +30,14 @@ export default async function RootLayout({
return (
<html
lang="en"
className={`${poppins.variable} ${GeistSans.variable} ${GeistMono.variable} ${inter.variable}`}
className={`${poppins.variable} ${GeistSans.variable} ${GeistMono.variable}`}
>
<head>
<GoogleAnalytics
gaId={process.env.GA_MEASUREMENT_ID || "G-FH2XK2W4GN"} // This is the measurement Id for the Google Analytics dev project
/>
</head>
<body
className={cn(
"bg-neutral-50 antialiased transition-colors",
inter.className,
)}
>
<body>
<Providers
attribute="class"
defaultTheme="light"
@@ -57,9 +48,6 @@ export default async function RootLayout({
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch">
{children}
<TallyPopupSimple />
<Suspense fallback={null}>
<OttoChatWidget />
</Suspense>
</div>
<Toaster />
</Providers>

View File

@@ -1,11 +1,12 @@
"use client";
import React, {
createContext,
useState,
useCallback,
useEffect,
useRef,
MouseEvent,
createContext,
Suspense,
} from "react";
import {
ReactFlow,
@@ -48,6 +49,7 @@ import RunnerUIWrapper, {
RunnerUIWrapperRef,
} from "@/components/RunnerUIWrapper";
import PrimaryActionBar from "@/components/PrimaryActionButton";
import OttoChatWidget from "@/components/OttoChatWidget";
import { useToast } from "@/components/ui/use-toast";
import { useCopyPaste } from "../hooks/useCopyPaste";
import { CronScheduler } from "./cronScheduler";
@@ -147,6 +149,13 @@ const FlowEditor: React.FC<{
// It stores the dimension of all nodes with position as well
const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({});
// Set page title with or without graph name
useEffect(() => {
document.title = savedAgent
? `${savedAgent.name} - Builder - AutoGPT Platform`
: `Builder - AutoGPT Platform`;
}, [savedAgent]);
useEffect(() => {
if (params.get("resetTutorial") === "true") {
localStorage.removeItem(TUTORIAL_STORAGE_KEY);
@@ -676,7 +685,7 @@ const FlowEditor: React.FC<{
<Controls />
<Background className="dark:bg-slate-800" />
<ControlPanel
className="absolute z-10"
className="absolute z-20"
controls={editorControls}
topChildren={
<BlocksControl
@@ -701,6 +710,7 @@ const FlowEditor: React.FC<{
}
></ControlPanel>
<PrimaryActionBar
className="absolute bottom-0 left-1/2 z-20 -translate-x-1/2"
onClickAgentOutputs={() => runnerUIRef.current?.openRunnerOutput()}
onClickRunAgent={() => {
if (!savedAgent) {
@@ -740,6 +750,12 @@ const FlowEditor: React.FC<{
scheduleRunner={scheduleRunner}
requestSaveAndRun={requestSaveAndRun}
/>
<Suspense fallback={null}>
<OttoChatWidget
graphID={flowID}
className="fixed bottom-4 right-4 z-20"
/>
</Suspense>
</FlowContext.Provider>
);
};

View File

@@ -1,32 +1,30 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useSearchParams, usePathname } from "next/navigation";
import { useToast } from "@/components/ui/use-toast";
import useAgentGraph from "../hooks/useAgentGraph";
import ReactMarkdown from "react-markdown";
import { GraphID } from "@/lib/autogpt-server-api/types";
import type { GraphID } from "@/lib/autogpt-server-api/types";
import { askOtto } from "@/app/(platform)/build/actions";
import { cn } from "@/lib/utils";
interface Message {
type: "user" | "assistant";
content: string;
}
const OttoChatWidget = () => {
export default function OttoChatWidget({
graphID,
className,
}: {
graphID?: GraphID;
className?: string;
}): React.ReactNode {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [includeGraphData, setIncludeGraphData] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const searchParams = useSearchParams();
const pathname = usePathname();
const flowID = searchParams.get("flowID");
const { nodes, edges } = useAgentGraph(
flowID ? (flowID as GraphID) : undefined,
);
const { toast } = useToast();
useEffect(() => {
// Add welcome message when component mounts
@@ -34,7 +32,7 @@ const OttoChatWidget = () => {
setMessages([
{
type: "assistant",
content: "Hello im Otto! Ask me anything about AutoGPT!",
content: "Hello, I am Otto! Ask me anything about AutoGPT!",
},
]);
}
@@ -84,7 +82,7 @@ const OttoChatWidget = () => {
userMessage,
conversationHistory,
includeGraphData,
flowID || undefined,
graphID,
);
// Check if the response contains an error
@@ -131,13 +129,13 @@ const OttoChatWidget = () => {
};
// Don't render the chat widget if we're not on the build page or in local mode
if (process.env.NEXT_PUBLIC_BEHAVE_AS !== "CLOUD" || pathname !== "/build") {
if (process.env.NEXT_PUBLIC_BEHAVE_AS !== "CLOUD") {
return null;
}
if (!isOpen) {
return (
<div className="fixed bottom-4 right-4 z-50">
<div className={className}>
<button
onClick={() => setIsOpen(true)}
className="inline-flex h-14 w-14 items-center justify-center whitespace-nowrap rounded-2xl bg-[rgba(65,65,64,1)] text-neutral-50 shadow transition-colors hover:bg-neutral-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90 dark:focus-visible:ring-neutral-300"
@@ -160,7 +158,13 @@ const OttoChatWidget = () => {
}
return (
<div className="fixed bottom-4 right-4 z-50 flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl">
<div
className={cn(
"flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl",
className,
"z-40",
)}
>
{/* Header */}
<div className="flex items-center justify-between border-b p-4">
<h2 className="font-semibold">Otto Assistant</h2>
@@ -269,7 +273,7 @@ const OttoChatWidget = () => {
Send
</button>
</div>
{nodes && edges && (
{graphID && (
<button
type="button"
onClick={() => {
@@ -303,6 +307,4 @@ const OttoChatWidget = () => {
</form>
</div>
);
};
export default OttoChatWidget;
}

View File

@@ -1,13 +1,14 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
import { Clock, LogOut, ChevronLeft } from "lucide-react";
import React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { FaSpinner } from "react-icons/fa";
import { Clock, LogOut } from "lucide-react";
import { IconPlay, IconSquare } from "@/components/ui/icons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FaSpinner } from "react-icons/fa";
interface PrimaryActionBarProps {
onClickAgentOutputs: () => void;
@@ -18,6 +19,7 @@ interface PrimaryActionBarProps {
isScheduling: boolean;
requestStopRun: () => void;
runAgentTooltip: string;
className?: string;
}
const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
@@ -29,6 +31,7 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
isScheduling,
requestStopRun,
runAgentTooltip,
className,
}) => {
const runButtonLabel = !isRunning ? "Run" : "Stop";
@@ -37,8 +40,13 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
const runButtonOnClick = !isRunning ? onClickRunAgent : requestStopRun;
return (
<div className="absolute bottom-0 left-1/2 z-50 flex w-fit -translate-x-1/2 transform select-none items-center justify-center p-4">
<div className={`flex gap-1 md:gap-4`}>
<div
className={cn(
"flex w-fit select-none items-center justify-center p-4",
className,
)}
>
<div className="flex gap-1 md:gap-4">
<Tooltip key="ViewOutputs" delayDuration={500}>
<TooltipTrigger asChild>
<Button

View File

@@ -1,11 +0,0 @@
import { LoaderCircle } from "lucide-react";
export default function Spinner({ className }: { className?: string }) {
const spinnerClasses = `mr-2 h-16 w-16 animate-spin ${className || ""}`;
return (
<div className="flex items-center justify-center">
<LoaderCircle className={spinnerClasses} />
</div>
);
}

View File

@@ -56,12 +56,12 @@ const TallyPopupSimple = () => {
};
return (
<div className="fixed bottom-1 right-24 z-50 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
<div className="fixed bottom-1 right-24 z-20 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
{show_tutorial && (
<Button
variant="default"
onClick={resetTutorial}
className="mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left font-inter text-lg font-medium leading-6"
className="mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left font-sans text-lg font-medium leading-6"
>
Tutorial
</Button>

View File

@@ -14,7 +14,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { useRouter } from "next/navigation";
import { addDollars } from "@/app/admin/spending/actions";
import { addDollars } from "@/app/(platform)/admin/spending/actions";
import useCredits from "@/hooks/useCredits";
export function AdminAddMoneyButton({
@@ -99,7 +99,6 @@ export function AdminAddMoneyButton({
id="dollarAmount"
type="number"
step="0.01"
min="0"
className="rounded-l-none"
value={dollarAmount}
onChange={(e) => setDollarAmount(e.target.value)}

View File

@@ -9,7 +9,7 @@ import {
import { PaginationControls } from "../../ui/pagination-controls";
import { SearchAndFilterAdminSpending } from "./search-filter-form";
import { getUsersTransactionHistory } from "@/app/admin/spending/actions";
import { getUsersTransactionHistory } from "@/app/(platform)/admin/spending/actions";
import { AdminAddMoneyButton } from "./add-money-button";
import { CreditTransactionType } from "@/lib/autogpt-server-api";

View File

@@ -17,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconRefresh, IconSquare } from "@/components/ui/icons";
import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import LoadingBox from "@/components/ui/loading";
import { Input } from "@/components/ui/input";
import {
@@ -133,7 +134,8 @@ export default function AgentRunDetailsView({
| null
| undefined = useMemo(() => {
if (!("outputs" in run)) return undefined;
if (!["running", "success", "failed"].includes(runStatus)) return null;
if (!["running", "success", "failed", "stopped"].includes(runStatus))
return null;
// Add type info from agent input schema
return Object.fromEntries(
@@ -251,7 +253,7 @@ export default function AgentRunDetailsView({
),
)
) : (
<p>Loading...</p>
<LoadingBox spinnerSize={12} className="h-24" />
)}
</CardContent>
</Card>
@@ -270,7 +272,7 @@ export default function AgentRunDetailsView({
</div>
))
) : (
<p>Loading...</p>
<LoadingBox spinnerSize={12} className="h-24" />
)}
</CardContent>
</Card>

View File

@@ -13,6 +13,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AgentRunStatus } from "@/components/agents/agent-run-status-chip";
import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import LoadingBox from "@/components/ui/loading";
import { Input } from "@/components/ui/input";
export default function AgentScheduleDetailsView({
@@ -113,7 +114,7 @@ export default function AgentScheduleDetailsView({
</div>
))
) : (
<p>Loading...</p>
<LoadingBox spinnerSize={12} className="h-24" />
)}
</CardContent>
</Card>

View File

@@ -99,7 +99,7 @@ export const AgentImageItem: React.FC<AgentImageItemProps> = React.memo(
}
}}
>
<span className="pr-1 font-neue text-sm font-medium leading-6 tracking-tight text-[#272727] dark:text-neutral-200 sm:pr-2 sm:text-base sm:leading-7 md:text-lg md:leading-8 lg:text-xl lg:leading-9">
<span className="pr-1 text-sm font-medium leading-6 tracking-tight text-[#272727] dark:text-neutral-200 sm:pr-2 sm:text-base sm:leading-7 md:text-lg md:leading-8 lg:text-xl lg:leading-9">
Play demo
</span>
<PlayIcon className="h-5 w-5 text-black dark:text-neutral-200 sm:h-6 sm:w-6 md:h-7 md:w-7" />

View File

@@ -133,19 +133,19 @@ export const AgentInfo: FC<AgentInfoProps> = ({
{/* Creator */}
<div className="mb-3 flex w-full items-center gap-1.5 lg:mb-4">
<div className="font-sans text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
<div className="text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
by
</div>
<Link
href={`/marketplace/creator/${encodeURIComponent(creator)}`}
className="font-sans text-base font-medium text-neutral-800 hover:underline dark:text-neutral-200 sm:text-lg lg:text-xl"
className="text-base font-medium text-neutral-800 hover:underline dark:text-neutral-200 sm:text-lg lg:text-xl"
>
{creator}
</Link>
</div>
{/* Short Description */}
<div className="mb-4 line-clamp-2 w-full font-sans text-base font-normal leading-normal text-neutral-600 dark:text-neutral-300 sm:text-lg lg:mb-6 lg:text-xl lg:leading-7">
<div className="mb-4 line-clamp-2 w-full text-base font-normal leading-normal text-neutral-600 dark:text-neutral-300 sm:text-lg lg:mb-6 lg:text-xl lg:leading-7">
{shortDescription}
</div>
@@ -182,12 +182,12 @@ export const AgentInfo: FC<AgentInfoProps> = ({
{/* Rating and Runs */}
<div className="mb-4 flex w-full items-center justify-between lg:mb-[44px]">
<div className="flex items-center gap-1.5 sm:gap-2">
<span className="whitespace-nowrap font-sans text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
<span className="whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{rating.toFixed(1)}
</span>
<div className="flex gap-0.5">{StarRatingIcons(rating)}</div>
</div>
<div className="whitespace-nowrap font-sans text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
<div className="whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{runs.toLocaleString()} runs
</div>
</div>
@@ -197,24 +197,24 @@ export const AgentInfo: FC<AgentInfoProps> = ({
{/* Description Section */}
<div className="mb-4 w-full lg:mb-[36px]">
<div className="mb-1.5 font-sans text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Description
</div>
<div className="whitespace-pre-line font-sans text-base font-normal leading-6 text-neutral-600 dark:text-neutral-400">
<div className="whitespace-pre-line text-base font-normal leading-6 text-neutral-600 dark:text-neutral-400">
{longDescription}
</div>
</div>
{/* Categories */}
<div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 lg:mb-[36px]">
<div className="decoration-skip-ink-none mb-1.5 font-sans text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Categories
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{categories.map((category, index) => (
<div
key={index}
className="decoration-skip-ink-none whitespace-nowrap rounded-full border border-neutral-600 bg-white px-2 py-0.5 font-sans text-base font-normal leading-6 text-neutral-800 underline-offset-[from-font] dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:px-[16px] sm:py-[10px]"
className="decoration-skip-ink-none whitespace-nowrap rounded-full border border-neutral-600 bg-white px-2 py-0.5 text-base font-normal leading-6 text-neutral-800 underline-offset-[from-font] dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:px-[16px] sm:py-[10px]"
>
{category}
</div>
@@ -224,10 +224,10 @@ export const AgentInfo: FC<AgentInfoProps> = ({
{/* Version History */}
<div className="flex w-full flex-col gap-0.5 sm:gap-1">
<div className="decoration-skip-ink-none mb-1.5 font-sans text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Version history
</div>
<div className="decoration-skip-ink-none font-sans text-base font-normal leading-6 text-neutral-600 underline-offset-[from-font] dark:text-neutral-400">
<div className="decoration-skip-ink-none text-base font-normal leading-6 text-neutral-600 underline-offset-[from-font] dark:text-neutral-400">
Last updated {lastUpdated}
</div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">

View File

@@ -37,7 +37,7 @@ export const BecomeACreator: React.FC<BecomeACreatorProps> = ({
vision
</h2>
<p className="font-geist mx-auto mb-8 max-w-[90%] text-lg font-normal leading-relaxed text-neutral-700 dark:text-neutral-300 md:mb-10 md:text-xl md:leading-loose lg:mb-14 lg:text-2xl">
<p className="mx-auto mb-8 max-w-[90%] text-lg font-normal leading-relaxed text-neutral-700 dark:text-neutral-300 md:mb-10 md:text-xl md:leading-loose lg:mb-14 lg:text-2xl">
{description}
</p>

View File

@@ -26,7 +26,7 @@ export const BreadCrumbs: React.FC<BreadCrumbsProps> = ({ items }) => {
{items.map((item, index) => (
<React.Fragment key={index}>
<Link href={item.link}>
<span className="rounded py-1 pr-2 font-neue text-xl font-medium leading-9 tracking-tight text-[#272727] transition-colors duration-200 hover:text-gray-400 dark:text-neutral-100 dark:hover:text-gray-500">
<span className="rounded py-1 pr-2 text-xl font-medium leading-9 tracking-tight text-[#272727] transition-colors duration-200 hover:text-gray-400 dark:text-neutral-100 dark:hover:text-gray-500">
{item.name}
</span>
</Link>

View File

@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center whitespace-nowrap overflow-hidden font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-neutral-300 font-neue leading-9 tracking-tight",
"inline-flex items-center whitespace-nowrap overflow-hidden font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-neutral-300 font-sans leading-9 tracking-tight",
{
variants: {
variant: {

View File

@@ -54,10 +54,10 @@ export const CreatorCard: React.FC<CreatorCardProps> = ({
<h3 className="font-poppins text-2xl font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
{creatorName}
</h3>
<p className="font-geist text-sm font-normal leading-normal text-neutral-600 dark:text-neutral-400">
<p className="text-sm font-normal leading-normal text-neutral-600 dark:text-neutral-400">
{bio}
</p>
<div className="font-geist text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
<div className="text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
{agentsUploaded} agents
</div>
</div>

View File

@@ -44,7 +44,7 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
<div className="w-full font-poppins text-[35px] font-medium leading-10 text-neutral-900 dark:text-neutral-100 sm:text-[35px] sm:leading-10">
{username}
</div>
<div className="font-geist w-full text-lg font-normal leading-6 text-neutral-800 dark:text-neutral-200 sm:text-xl sm:leading-7">
<div className="w-full text-lg font-normal leading-6 text-neutral-800 dark:text-neutral-200 sm:text-xl sm:leading-7">
@{handle}
</div>
</div>
@@ -54,7 +54,7 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
<div className="flex w-full flex-col items-start justify-start gap-3">
<div className="h-px w-full bg-neutral-700 dark:bg-neutral-300" />
<div className="flex flex-col items-start justify-start gap-2.5">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="w-full text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Top categories
</div>
<div
@@ -68,7 +68,7 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
className="flex items-center justify-center gap-2.5 rounded-[34px] border border-neutral-600 px-4 py-3 dark:border-neutral-400"
role="listitem"
>
<div className="font-neue text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
<div className="text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
{category}
</div>
</div>
@@ -81,11 +81,11 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
<div className="h-px w-full bg-neutral-700 dark:bg-neutral-300" />
<div className="flex w-full flex-col items-start justify-between gap-4 sm:flex-row sm:gap-0">
<div className="flex w-full flex-col items-start justify-start gap-2.5 sm:w-[164px]">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="w-full text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Average rating
</div>
<div className="inline-flex items-center gap-2">
<div className="font-geist text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
<div className="text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
{averageRating.toFixed(1)}
</div>
<div
@@ -98,10 +98,10 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
</div>
</div>
<div className="flex w-full flex-col items-start justify-start gap-2.5 sm:w-[164px]">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="w-full text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Number of runs
</div>
<div className="font-geist text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
<div className="text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
{new Intl.NumberFormat().format(totalRuns)} runs
</div>
</div>

View File

@@ -17,7 +17,7 @@ export const CreatorLinks: React.FC<CreatorLinksProps> = ({ links }) => {
rel="noopener noreferrer"
className="flex min-w-[200px] flex-1 items-center justify-between rounded-[34px] border border-neutral-600 px-5 py-3 dark:border-neutral-400"
>
<div className="font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
{new URL(url).hostname.replace("www.", "")}
</div>
<div className="relative h-6 w-6">
@@ -30,7 +30,7 @@ export const CreatorLinks: React.FC<CreatorLinksProps> = ({ links }) => {
return (
<div className="flex flex-col items-start justify-start gap-4">
<div className="font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Other links
</div>
<div className="flex w-full flex-wrap gap-3">

View File

@@ -44,7 +44,7 @@ export const FilterChips: React.FC<FilterChipsProps> = ({
className="mb-2 flex cursor-pointer items-center justify-center gap-2 rounded-full border border-black/50 px-3 py-1 dark:border-white/50 lg:mb-3 lg:gap-2.5 lg:px-6 lg:py-2"
onClick={() => handleBadgeClick(badge)}
>
<div className="font-neue text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
<div className="text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
{badge}
</div>
</Badge>

View File

@@ -87,7 +87,7 @@ const PopoutMenuItem: React.FC<{
{getIcon(icon)}
<div className="relative">
<div
className={`font-inter text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf] ${isActive ? "font-semibold text-[#272727] dark:text-[#ffffff]" : "text-[#474747] dark:text-[#cfcfcf]"}`}
className={`font-sans text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf] ${isActive ? "font-semibold text-[#272727] dark:text-[#ffffff]" : "text-[#474747] dark:text-[#cfcfcf]"}`}
>
{text}
</div>
@@ -164,7 +164,7 @@ export const MobileNavBar: React.FC<MobileNavBarProps> = ({
<div className="absolute left-0 top-0 text-lg font-semibold leading-7 text-[#474747] dark:text-[#cfcfcf]">
{userName || "Unknown User"}
</div>
<div className="absolute left-0 top-6 font-inter text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf]">
<div className="absolute left-0 top-6 font-sans text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf]">
{userEmail || "No Email Set"}
</div>
</div>

View File

@@ -40,7 +40,7 @@ export const PublishAgentAwaitingReview: React.FC<
>
Agent is awaiting review
</div>
<div className="max-w-[280px] text-center font-inter text-sm font-normal leading-relaxed text-slate-500 dark:text-slate-400 sm:max-w-none">
<div className="max-w-[280px] text-center font-sans text-sm font-normal leading-relaxed text-slate-500 dark:text-slate-400 sm:max-w-none">
In the meantime you can check your progress on your Creator
Dashboard page
</div>

View File

@@ -66,7 +66,7 @@ export const PublishAgentSelect: React.FC<PublishAgentSelectProps> = ({
<h3 className="font-poppins text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Publish Agent
</h3>
<p className="font-geist text-sm font-normal text-neutral-600 dark:text-neutral-400">
<p className="text-sm font-normal text-neutral-600 dark:text-neutral-400">
Select your project that you&apos;d like to publish
</p>
</div>
@@ -135,7 +135,7 @@ export const PublishAgentSelect: React.FC<PublishAgentSelectProps> = ({
<p className="font-poppins text-base font-medium leading-normal text-neutral-800 dark:text-neutral-100 sm:text-base">
{agent.name}
</p>
<small className="font-geist text-xs font-normal leading-[14px] text-neutral-500 dark:text-neutral-400 sm:text-sm">
<small className="text-xs font-normal leading-[14px] text-neutral-500 dark:text-neutral-400 sm:text-sm">
Edited {agent.lastEdited}
</small>
</div>

View File

@@ -34,10 +34,10 @@ export const SortDropdown: React.FC<{
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 focus:outline-none">
<span className="font-geist text-base text-neutral-800 dark:text-neutral-200">
<span className="text-base text-neutral-800 dark:text-neutral-200">
Sort by
</span>
<span className="font-geist text-base text-neutral-800 dark:text-neutral-200">
<span className="text-base text-neutral-800 dark:text-neutral-200">
{selected.label}
</span>
<ChevronDownIcon className="h-4 w-4 text-neutral-800 dark:text-neutral-200" />

View File

@@ -87,7 +87,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
{/* Third Section: Description */}
<div className="mt-2.5 flex w-full flex-col">
<p className="line-clamp-3 font-sans text-base font-normal leading-normal text-neutral-600 dark:text-neutral-400">
<p className="line-clamp-3 text-base font-normal leading-normal text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
@@ -98,11 +98,11 @@ export const StoreCard: React.FC<StoreCardProps> = ({
{/* Fourth Section: Stats Row - aligned to bottom */}
<div className="mt-5 w-full">
<div className="flex items-center justify-between">
<div className="font-sans text-lg font-semibold text-neutral-800 dark:text-neutral-200">
<div className="text-lg font-semibold text-neutral-800 dark:text-neutral-200">
{runs.toLocaleString()} runs
</div>
<div className="flex items-center gap-2">
<span className="font-sans text-lg font-semibold text-neutral-800 dark:text-neutral-200">
<span className="text-lg font-semibold text-neutral-800 dark:text-neutral-200">
{rating.toFixed(1)}
</span>
<div

View File

@@ -140,7 +140,7 @@ export default function Wallet() {
<span className="font-poppins font-medium text-zinc-900">
Your wallet
</span>
<div className="flex items-center font-inter text-sm font-semibold text-violet-700">
<div className="flex items-center text-sm font-semibold text-violet-700">
<div className="rounded-lg bg-violet-100 px-3 py-2">
Wallet{" "}
<span className="font-semibold">{formatCredits(credits)}</span>

View File

@@ -32,7 +32,7 @@ export const AgentsSection: React.FC<AgentsSectionProps> = ({
sectionTitle,
agents: allAgents,
hideAvatars = false,
margin = "37px",
margin = "24px",
}) => {
const router = useRouter();
@@ -48,11 +48,12 @@ export const AgentsSection: React.FC<AgentsSectionProps> = ({
return (
<div className="flex flex-col items-center justify-center">
<div className="w-full max-w-[1360px]">
<div
className={`mb-[${margin}] font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200`}
<h2
style={{ marginBottom: margin }}
className="font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200"
>
{sectionTitle}
</div>
</h2>
{!displayedAgents || displayedAgents.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400">
No agents found

View File

@@ -10,14 +10,14 @@ interface LibraryActionHeaderProps {}
const LibraryActionHeader: React.FC<LibraryActionHeaderProps> = ({}) => {
return (
<>
<div className="mb-[32px] hidden items-start justify-between bg-neutral-50 md:flex">
<div className="mb-[32px] hidden items-start justify-between md:flex">
{/* <LibraryNotificationDropdown /> */}
<LibrarySearchBar />
<LibraryUploadAgentDialog />
</div>
{/* Mobile and tablet */}
<div className="flex flex-col gap-4 bg-neutral-50 p-4 pt-[52px] md:hidden">
<div className="flex flex-col gap-4 p-4 pt-[52px] md:hidden">
<div className="flex w-full justify-between">
{/* <LibraryNotificationDropdown /> */}
<LibraryUploadAgentDialog />

View File

@@ -79,7 +79,7 @@ export default function LibraryAgentCard({
<div className="items-between mt-4 flex w-full justify-between gap-3">
<Link
href={`/library/agents/${id}`}
className="font-geist text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
>
See runs
</Link>
@@ -87,7 +87,7 @@ export default function LibraryAgentCard({
{can_access_graph && (
<Link
href={`/build?flowID=${agent_id}`}
className="font-geist text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
>
Open in builder
</Link>

View File

@@ -219,7 +219,6 @@ export default function LibraryUploadAgentDialog(): React.ReactNode {
justifyContent: "center",
alignItems: "center",
outline: "none",
fontFamily: "var(--font-geist-sans)",
color: "#525252",
fontSize: "14px",
fontWeight: "500",

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { LoadingSpinner } from "@/components/ui/loading";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import Spinner from "../Spinner";
const variants = {
default: "bg-zinc-700 hover:bg-zinc-800",
@@ -55,7 +55,7 @@ export default function OnboardingButton({
if (href && !disabled) {
return (
<Link href={href} onClick={onClickInternal} className={buttonClasses}>
{isLoading && <Spinner className="h-5 w-5" />}
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</Link>
@@ -68,7 +68,7 @@ export default function OnboardingButton({
disabled={disabled}
className={buttonClasses}
>
{isLoading && <Spinner className="h-5 w-5" />}
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</button>

View File

@@ -41,7 +41,7 @@ export default function StarRating({
return (
<div
className={cn(
"font-geist flex items-center gap-0.5 text-sm font-medium text-zinc-800",
"flex items-center gap-0.5 text-sm font-medium text-zinc-800",
className,
)}
>

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
import { LoaderCircle } from "lucide-react";
export default function LoadingBox({
className,
spinnerSize,
}: {
className?: string;
spinnerSize?: string | number;
}) {
const spinnerSizeClass =
typeof spinnerSize == "string"
? `size-[${spinnerSize}]`
: typeof spinnerSize == "number"
? `size-${spinnerSize}`
: undefined;
return (
<div className={cn("flex items-center justify-center", className)}>
<LoadingSpinner className={spinnerSizeClass} />
</div>
);
}
export function LoadingSpinner({ className }: { className?: string }) {
return <LoaderCircle className={cn("size-16 animate-spin", className)} />;
}

View File

@@ -13,10 +13,18 @@ import { useToast } from "@/components/ui/use-toast";
export function Toaster() {
const { toasts } = useToast();
// This neat little feature makes the toaster buggy due to the following issue:
// https://github.com/radix-ui/primitives/issues/2233
// TODO: Re-enable when the above issue is fixed:
// const swipeThreshold = toasts.some((toast) => toast.dismissable === false)
// ? Infinity
// : undefined;
const swipeThreshold = undefined;
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<ToastProvider swipeThreshold={swipeThreshold}>
{toasts.map(
({ id, title, description, action, dismissable, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
@@ -25,10 +33,10 @@ export function Toaster() {
)}
</div>
{action}
<ToastClose />
{dismissable !== false && <ToastClose />}
</Toast>
);
})}
),
)}
<ToastViewport />
</ToastProvider>
);

View File

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

View File

@@ -68,6 +68,7 @@ export default class BackendAPI {
private webSocket: WebSocket | null = null;
private wsConnecting: Promise<void> | null = null;
private wsOnConnectHandlers: Set<() => void> = new Set();
private wsOnDisconnectHandlers: Set<() => void> = new Set();
private wsMessageHandlers: Record<string, Set<(data: any) => void>> = {};
readonly HEARTBEAT_INTERVAL = 100_000; // 100 seconds
@@ -939,43 +940,69 @@ export default class BackendAPI {
return () => this.wsOnConnectHandlers.delete(handler);
}
/**
* All handlers are invoked when the WebSocket disconnects.
*
* @returns a detacher for the passed handler.
*/
onWebSocketDisconnect(handler: () => void): () => void {
this.wsOnDisconnectHandlers.add(handler);
// Return detacher
return () => this.wsOnDisconnectHandlers.delete(handler);
}
async connectWebSocket(): Promise<void> {
this.wsConnecting ??= new Promise(async (resolve, reject) => {
return (this.wsConnecting ??= new Promise(async (resolve, reject) => {
try {
const token =
(await this.supabaseClient?.auth.getSession())?.data.session
?.access_token || "";
const wsUrlWithToken = `${this.wsUrl}?token=${token}`;
this.webSocket = new WebSocket(wsUrlWithToken);
this.webSocket.state = "connecting";
this.webSocket.onopen = () => {
this.webSocket!.state = "connected";
console.info("[BackendAPI] WebSocket connected to", this.wsUrl);
this._startWSHeartbeat(); // Start heartbeat when connection opens
this.wsOnConnectHandlers.forEach((handler) => handler());
resolve();
};
this.webSocket.onclose = (event) => {
console.warn("WebSocket connection closed", event);
if (this.webSocket?.state == "connecting") {
console.error(
`[BackendAPI] WebSocket failed to connect: ${event.reason}`,
event,
);
} else if (this.webSocket?.state == "connected") {
console.warn(
`[BackendAPI] WebSocket connection closed: ${event.reason}`,
event,
);
}
this.webSocket!.state = "closed";
this._stopWSHeartbeat(); // Stop heartbeat when connection closes
this.wsConnecting = null;
this.wsOnDisconnectHandlers.forEach((handler) => handler());
// Attempt to reconnect after a delay
setTimeout(() => this.connectWebSocket(), 1000);
setTimeout(() => this.connectWebSocket().then(resolve), 1000);
};
this.webSocket.onerror = (error) => {
console.error("WebSocket error:", error);
this._stopWSHeartbeat(); // Stop heartbeat on error
this.wsConnecting = null;
reject(error);
if (this.webSocket?.state == "connected") {
console.error("[BackendAPI] WebSocket error:", error);
}
};
this.webSocket.onmessage = (event) => this._handleWSMessage(event);
} catch (error) {
console.error("Error connecting to WebSocket:", error);
console.error("[BackendAPI] Error connecting to WebSocket:", error);
reject(error);
}
});
return this.wsConnecting;
}));
}
disconnectWebSocket() {
@@ -1044,6 +1071,12 @@ export default class BackendAPI {
}
}
declare global {
interface WebSocket {
state: "connecting" | "connected" | "closed";
}
}
/* *** UTILITY TYPES *** */
type GraphCreateRequestBody = {

View File

@@ -4,5 +4,5 @@ test("has title", async ({ page }) => {
await page.goto("/");
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/NextGen AutoGPT/);
await expect(page).toHaveTitle(/AutoGPT Platform/);
});

View File

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

View File

@@ -99,8 +99,6 @@ Below is a comprehensive list of all available blocks, categorized by their prim
| Block Name | Description |
|------------|-------------|
| [Gmail Read](google/gmail.md#gmail-read) | Retrieves and reads emails from a Gmail account |
| [Gmail Get Thread](google/gmail.md#gmail-get-thread) | Returns every message in a Gmail thread |
| [Gmail Reply](google/gmail.md#gmail-reply) | Sends a reply that stays in the same thread |
| [Gmail Send](google/gmail.md#gmail-send) | Sends emails using a Gmail account |
| [Gmail List Labels](google/gmail.md#gmail-list-labels) | Retrieves all labels from a Gmail account |
| [Gmail Add Label](google/gmail.md#gmail-add-label) | Adds a label to a specific email in a Gmail account |

View File

@@ -21,7 +21,7 @@ The block connects to the user's Gmail account using their credentials, performs
### Outputs
| Output | Description |
|--------|-------------|
| Email | Detailed information about a single email (now includes `threadId`) |
| Email | Detailed information about a single email |
| Emails | A list of email data for multiple emails |
| Error | An error message if something goes wrong during the process |
@@ -141,64 +141,4 @@ The block first finds the ID of the specified label in the user's Gmail account.
| Error | An error message if something goes wrong during the process |
### Possible use case
Automatically removing the "Unread" label from emails after they have been processed by a customer service representative.
---
## Gmail Get Thread
### What it is
A block that retrieves an entire Gmail thread.
### What it does
Given a `threadId`, this block fetches all messages in that thread. You can optionally include messages in Spam and Trash.
### Inputs
| Input | Description |
|-------|-------------|
| Credentials | The user's Gmail account credentials for authentication |
| threadId | The ID of the thread to fetch |
| includeSpamTrash | Whether to include messages from Spam and Trash |
### Outputs
| Output | Description |
|--------|-------------|
| Thread | The raw Gmail thread resource |
| Error | An error message if something goes wrong |
### Possible use case
Checking if a recipient replied in an existing conversation.
---
## Gmail Reply
### What it is
A block that sends a reply within an existing Gmail thread.
### What it does
This block builds a properly formatted reply email and sends it so Gmail keeps it in the same conversation.
### Inputs
| Input | Description |
|-------|-------------|
| Credentials | The user's Gmail account credentials for authentication |
| threadId | The thread to reply in |
| parentMessageId | The ID of the message you are replying to |
| To | List of recipients |
| Cc | List of CC recipients |
| Bcc | List of BCC recipients |
| Subject | Optional subject (defaults to `Re:` prefix) |
| Body | The email body |
| Attachments | Optional files to include |
### Outputs
| Output | Description |
|--------|-------------|
| MessageId | The ID of the sent message |
| ThreadId | The thread the reply belongs to |
| Message | Full Gmail message object |
| Error | Error message if something goes wrong |
### Possible use case
Automatically respond "Thanks, see you then" to a scheduling email while keeping the conversation tidy.
Automatically removing the "Unread" label from emails after they have been processed by a customer service representative.