Compare commits

..

2 Commits

Author SHA1 Message Date
Toran Bruce Richards
3f818ef157 Delete docs/content/platform/quickstarts/gmail-detect-reply.md 2025-05-17 20:31:01 +01:00
Toran Bruce Richards
636574a720 Add Gmail thread handling blocks 2025-05-17 13:42:07 +01:00
72 changed files with 560 additions and 573 deletions

View File

@@ -1,50 +0,0 @@
# 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

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

View File

@@ -78,9 +78,6 @@ 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__(
@@ -143,3 +140,4 @@ class ExaSearchBlock(Block):
yield "results", data.get("results", [])
except Exception as e:
yield "error", str(e)
yield "results", []

View File

@@ -67,7 +67,6 @@ 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__(
@@ -126,3 +125,4 @@ class ExaFindSimilarBlock(Block):
yield "results", data.get("results", [])
except Exception as e:
yield "error", str(e)
yield "results", []

View File

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

View File

@@ -9,6 +9,8 @@ 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,
@@ -28,6 +30,7 @@ class Attachment(BaseModel):
class Email(BaseModel):
threadId: str
id: str
subject: str
snippet: str
@@ -82,6 +85,7 @@ class GmailReadBlock(Block):
(
"email",
{
"threadId": "t1",
"id": "1",
"subject": "Test Email",
"snippet": "This is a test email",
@@ -97,6 +101,7 @@ class GmailReadBlock(Block):
"emails",
[
{
"threadId": "t1",
"id": "1",
"subject": "Test Email",
"snippet": "This is a test email",
@@ -113,6 +118,7 @@ class GmailReadBlock(Block):
test_mock={
"_read_emails": lambda *args, **kwargs: [
{
"threadId": "t1",
"id": "1",
"subject": "Test Email",
"snippet": "This is a test email",
@@ -185,6 +191,7 @@ 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"],
@@ -528,3 +535,180 @@ 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,8 +101,6 @@ 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"
@@ -186,12 +184,6 @@ 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,10 +124,8 @@ 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": str(input_data.content)}]
messages = [{"role": "user", "content": input_data.content}]
params = {
"user_id": user_id,
@@ -154,7 +152,7 @@ class AddMemoryBlock(Block, Mem0Base):
yield "action", "NO_CHANGE"
except Exception as e:
yield "error", str(e)
yield "error", str(object=e)
class SearchMemoryBlock(Block, Mem0Base):

View File

@@ -47,8 +47,6 @@ 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,7 +24,6 @@ from prisma.models import (
)
from prisma.types import (
AgentGraphExecutionCreateInput,
AgentGraphExecutionUpdateManyMutationInput,
AgentGraphExecutionWhereInput,
AgentNodeExecutionCreateInput,
AgentNodeExecutionInputOutputCreateInput,
@@ -573,15 +572,9 @@ async def update_graph_execution_stats(
status: ExecutionStatus,
stats: GraphExecutionStats | None = None,
) -> GraphExecution | None:
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)
data = stats.model_dump() if stats else {}
if isinstance(data.get("error"), Exception):
data["error"] = str(data["error"])
updated_count = await AgentGraphExecution.prisma().update_many(
where={
@@ -591,7 +584,10 @@ async def update_graph_execution_stats(
{"executionStatus": ExecutionStatus.QUEUED},
],
},
data=update_data,
data={
"executionStatus": status,
"stats": Json(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] = None
title: Optional[str]
@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] = None
username: Optional[str]
"""Username of the third-party service user that these credentials belong to"""
access_token: SecretStr
access_token_expires_at: Optional[int] = None
access_token_expires_at: Optional[int]
"""Unix timestamp (seconds) indicating when the access token expires (if at all)"""
refresh_token: Optional[SecretStr] = None
refresh_token_expires_at: Optional[int] = None
refresh_token: Optional[SecretStr]
refresh_token_expires_at: Optional[int]
"""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(exclude_none=True))
encrypted_data = JSONCryptor().encrypt(data.model_dump())
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 continuous_retry, func_retry
from backend.util.retry import func_retry
from backend.util.service import get_service_client
from backend.util.settings import Settings
@@ -938,6 +938,9 @@ 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)
@@ -963,29 +966,22 @@ 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...")
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}")
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()
@continuous_retry()
def _consume_execution_run(self):
run_client = SyncRabbitMQ(create_execution_queue_config())
run_client.connect()
run_channel = run_client.get_channel()
@@ -997,7 +993,6 @@ 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,
@@ -1096,6 +1091,10 @@ 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...")
@@ -1112,7 +1111,6 @@ class ExecutionManager(AppProcess):
redis.disconnect()
log(f"{prefix} ✅ Finished GraphExec cleanup")
sys.exit(0)
# ------- UTILITIES ------- #

View File

@@ -84,7 +84,7 @@ if TYPE_CHECKING:
@thread_cached
def execution_scheduler_client() -> scheduler.SchedulerClient:
return get_service_client(scheduler.SchedulerClient, health_check=False)
return get_service_client(scheduler.SchedulerClient)
@thread_cached
@@ -660,15 +660,11 @@ async def _cancel_execution(graph_exec_id: str):
exchange=execution_utils.GRAPH_EXECUTION_CANCEL_EXCHANGE,
)
# Update the status of the graph execution
graph_execution = await execution_db.update_graph_execution_stats(
# Update the status of the graph & node executions
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(
@@ -680,6 +676,7 @@ 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

@@ -1,4 +1,3 @@
import datetime
import logging
from typing import Optional
@@ -30,7 +29,7 @@ integration_creds_manager = IntegrationCredentialsManager()
async def list_library_agents(
user_id: str,
search_term: Optional[str] = None,
sort_by: library_model.LibraryAgentSort = library_model.LibraryAgentSort.LAST_EXECUTED_AT,
sort_by: library_model.LibraryAgentSort = library_model.LibraryAgentSort.UPDATED_AT,
page: int = 1,
page_size: int = 50,
) -> library_model.LibraryAgentResponse:
@@ -96,23 +95,16 @@ async def list_library_agents(
order_by = {"updatedAt": "desc"}
try:
if sort_by == library_model.LibraryAgentSort.LAST_EXECUTED_AT:
library_agents = await prisma.models.LibraryAgent.prisma().find_many(
where=where_clause,
include=library_agent_include(user_id),
)
agent_count = len(library_agents)
else:
library_agents = await prisma.models.LibraryAgent.prisma().find_many(
where=where_clause,
include=library_agent_include(user_id),
order=order_by,
skip=(page - 1) * page_size,
take=page_size,
)
agent_count = await prisma.models.LibraryAgent.prisma().count(
where=where_clause
)
library_agents = await prisma.models.LibraryAgent.prisma().find_many(
where=where_clause,
include=library_agent_include(user_id),
order=order_by,
skip=(page - 1) * page_size,
take=page_size,
)
agent_count = await prisma.models.LibraryAgent.prisma().count(
where=where_clause
)
logger.debug(
f"Retrieved {len(library_agents)} library agents for user #{user_id}"
@@ -132,15 +124,7 @@ async def list_library_agents(
)
continue
if sort_by == library_model.LibraryAgentSort.LAST_EXECUTED_AT:
valid_library_agents.sort(
key=lambda a: a.last_executed_at
or datetime.datetime.min.replace(tzinfo=datetime.timezone.utc),
reverse=True,
)
start = (page - 1) * page_size
valid_library_agents = valid_library_agents[start : start + page_size]
# Return the response with only valid agents
return library_model.LibraryAgentResponse(
agents=valid_library_agents,
pagination=backend.server.model.Pagination(

View File

@@ -36,8 +36,6 @@ class LibraryAgent(pydantic.BaseModel):
status: LibraryAgentStatus
updated_at: datetime.datetime
# Most recent time this agent was executed
last_executed_at: Optional[datetime.datetime] = None
name: str
description: str
@@ -90,10 +88,6 @@ class LibraryAgent(pydantic.BaseModel):
status = status_result.status
new_output = status_result.new_output
last_executed_at = None
if executions:
last_executed_at = max(exec.createdAt for exec in executions)
# Check if user can access the graph
can_access_graph = agent.AgentGraph.userId == agent.userId
@@ -109,7 +103,6 @@ class LibraryAgent(pydantic.BaseModel):
creator_image_url=creator_image_url,
status=status,
updated_at=updated_at,
last_executed_at=last_executed_at,
name=graph.name,
description=graph.description,
input_schema=graph.input_schema,
@@ -242,7 +235,6 @@ class LibraryAgentSort(str, Enum):
CREATED_AT = "createdAt"
UPDATED_AT = "updatedAt"
LAST_EXECUTED_AT = "lastExecutedAt"
class LibraryAgentUpdateRequest(pydantic.BaseModel):

View File

@@ -30,7 +30,7 @@ async def list_library_agents(
None, description="Search term to filter agents"
),
sort_by: library_model.LibraryAgentSort = Query(
library_model.LibraryAgentSort.LAST_EXECUTED_AT,
library_model.LibraryAgentSort.UPDATED_AT,
description="Criteria to sort results by",
),
page: int = Query(

View File

@@ -85,7 +85,7 @@ async def test_get_library_agents_success(mocker: pytest_mock.MockFixture):
mock_db_call.assert_called_once_with(
user_id="test-user-id",
search_term="test",
sort_by=library_model.LibraryAgentSort.LAST_EXECUTED_AT,
sort_by=library_model.LibraryAgentSort.UPDATED_AT,
page=1,
page_size=15,
)
@@ -100,7 +100,7 @@ def test_get_library_agents_error(mocker: pytest_mock.MockFixture):
mock_db_call.assert_called_once_with(
user_id="test-user-id",
search_term="test",
sort_by=library_model.LibraryAgentSort.LAST_EXECUTED_AT,
sort_by=library_model.LibraryAgentSort.UPDATED_AT,
page=1,
page_size=15,
)

View File

@@ -2,9 +2,8 @@ import ipaddress
import re
import socket
import ssl
from typing import Callable, Optional
from urllib.parse import ParseResult as URL
from urllib.parse import quote, urljoin, urlparse
from typing import Callable
from urllib.parse import quote, urljoin, urlparse, urlunparse
import idna
import requests as req
@@ -45,15 +44,17 @@ 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: URL, new_url: URL) -> dict:
def _remove_insecure_headers(headers: dict, old_url: str, new_url: str) -> 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_url.scheme != new_url.scheme)
or (old_url.hostname != new_url.hostname)
or (old_url.port != new_url.port)
(old_parsed.scheme != new_parsed.scheme)
or (old_parsed.hostname != new_parsed.hostname)
or (old_parsed.port != new_parsed.port)
):
headers.pop("Authorization", None)
headers.pop("Proxy-Authorization", None)
@@ -80,16 +81,19 @@ class HostSSLAdapter(HTTPAdapter):
)
def validate_url(url: str, trusted_origins: list[str]) -> tuple[URL, bool, list[str]]:
def validate_url(
url: str,
trusted_origins: list[str],
enable_dns_rebinding: bool = True,
) -> tuple[str, 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:
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
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
"""
# Canonicalize URL
url = url.strip("/ ").replace("\\", "/")
@@ -118,56 +122,45 @@ def validate_url(url: str, trusted_origins: list[str]) -> tuple[URL, bool, list[
if not HOSTNAME_REGEX.match(ascii_hostname):
raise ValueError("Hostname contains invalid characters.")
# Check if hostname is trusted
is_trusted = ascii_hostname in trusted_origins
# 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}"
# 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)
pinned_url = urlunparse(
(
parsed.scheme,
pinned_netloc,
quote(parsed.path, safe="/%:@"),
parsed.params,
parsed.query,
parsed.fragment,
)
)
return pinned_url, 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}")
# 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}")
if not ip_addresses:
# Resolve all IP addresses for the hostname
ip_addresses = _resolve_host(url.hostname)
raise ValueError(f"No IP addresses found for {ascii_hostname}")
# Pin to the first valid IP (for SSRF defense)
# 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).
pinned_ip = ip_addresses[0]
# If it's IPv6, bracket it
@@ -176,31 +169,24 @@ def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL:
else:
pinned_netloc = pinned_ip
if url.port:
pinned_netloc += f":{url.port}"
if parsed.port:
pinned_netloc += f":{parsed.port}"
return URL(
url.scheme,
pinned_netloc,
url.path,
url.params,
url.query,
url.fragment,
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,
)
)
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
return pinned_url, ascii_hostname # (pinned_url, original_hostname)
class Requests:
@@ -214,7 +200,7 @@ class Requests:
self,
trusted_origins: list[str] | None = None,
raise_for_status: bool = True,
extra_url_validator: Callable[[URL], URL] | None = None,
extra_url_validator: Callable[[str], str] | None = None,
extra_headers: dict[str, str] | None = None,
):
self.trusted_origins = []
@@ -238,18 +224,12 @@ 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)
# Pin the URL if untrusted
hostname = url.hostname
original_url = url.geturl()
if not is_trusted:
url = pin_url(url, ip_addresses)
# Validate URL and get pinned URL + hostname
pinned_url, hostname = validate_url(url, self.trusted_origins)
# Merge any extra headers
headers = dict(headers) if headers else {}
@@ -260,30 +240,27 @@ 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 url.hostname != hostname:
if (pinned := urlparse(pinned_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,
url.geturl(),
pinned_url,
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()
@@ -298,13 +275,13 @@ class Requests:
# The base URL is the pinned_url we just used
# so that relative redirects resolve correctly.
redirect_url = urlparse(urljoin(url.geturl(), location))
new_url = urljoin(pinned_url, location)
# Carry forward the same headers but update Host
new_headers = _remove_insecure_headers(headers, url, redirect_url)
new_headers = _remove_insecure_headers(dict(headers), url, new_url)
return self.request(
method,
redirect_url.geturl(),
new_url,
headers=new_headers,
allow_redirects=allow_redirects,
max_redirects=max_redirects - 1,

View File

@@ -2,7 +2,6 @@ import asyncio
import logging
import os
import threading
import time
from functools import wraps
from uuid import uuid4
@@ -81,24 +80,3 @@ 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 pin_url, validate_url
from backend.util.request import validate_url
@pytest.mark.parametrize(
"raw_url, trusted_origins, expected_value, should_raise",
"url, trusted_origins, expected_value, should_raise",
[
# Rejected IP ranges
("localhost", [], None, True),
@@ -55,14 +55,14 @@ from backend.util.request import pin_url, validate_url
],
)
def test_validate_url_no_dns_rebinding(
raw_url: str, trusted_origins: list[str], expected_value: str, should_raise: bool
url, trusted_origins, expected_value, should_raise
):
if should_raise:
with pytest.raises(ValueError):
validate_url(raw_url, trusted_origins)
validate_url(url, trusted_origins, enable_dns_rebinding=False)
else:
validated_url, _, _ = validate_url(raw_url, trusted_origins)
assert validated_url.geturl() == expected_value
url, host = validate_url(url, trusted_origins, enable_dns_rebinding=False)
assert url == expected_value
@pytest.mark.parametrize(
@@ -79,11 +79,7 @@ def test_validate_url_no_dns_rebinding(
],
)
def test_dns_rebinding_fix(
monkeypatch,
hostname: str,
resolved_ips: list[str],
expect_error: bool,
expected_ip: str,
monkeypatch, hostname, resolved_ips, expect_error, expected_ip
):
"""
Tests that validate_url pins the first valid public IP address, and rejects
@@ -100,13 +96,11 @@ def test_dns_rebinding_fix(
if expect_error:
# If any IP is blocked, we expect a ValueError
with pytest.raises(ValueError):
url, _, ip_addresses = validate_url(hostname, [])
pin_url(url, ip_addresses)
validate_url(hostname, [])
else:
url, _, ip_addresses = validate_url(hostname, [])
pinned_url = pin_url(url, ip_addresses).geturl()
pinned_url, ascii_hostname = validate_url(hostname, [])
# 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 unpinned URL's hostname should match our original IDNA encoded hostname
assert url.hostname == hostname
# The ascii_hostname should match our original hostname after IDNA encoding
assert ascii_hostname == hostname

View File

@@ -39,11 +39,9 @@ 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();
@@ -71,6 +69,7 @@ export default function AgentRunsPage(): React.ReactElement {
const { state: onboardingState, updateState: updateOnboardingState } =
useOnboarding();
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
const { toast } = useToast();
const openRunDraftView = useCallback(() => {
selectView({ type: "run" });
@@ -121,11 +120,7 @@ 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);
@@ -161,44 +156,6 @@ 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
@@ -357,7 +314,8 @@ export default function AgentRunsPage(): React.ReactElement {
);
if (!agent || !graph) {
return <LoadingBox className="h-[90vh]" />;
/* TODO: implement loading indicators / skeleton page */
return <span>Loading...</span>;
}
return (
@@ -415,7 +373,7 @@ export default function AgentRunsPage(): React.ReactElement {
agentActions={agentActions}
/>
)
) : null) || <LoadingBox className="h-[70vh]" />}
) : null) || <p>Loading...</p>}
<DeleteConfirmDialog
entityType="agent"

View File

@@ -37,7 +37,7 @@ export function LibraryPageStateProvider({
const [searchTerm, setSearchTerm] = useState<string | undefined>("");
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [librarySort, setLibrarySort] = useState<LibraryAgentSortEnum>(
LibraryAgentSortEnum.LAST_EXECUTED_AT,
LibraryAgentSortEnum.UPDATED_AT,
);
return (

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 LoadingBox from "@/components/ui/loading";
import Spinner from "@/components/Spinner";
import {
AuthCard,
AuthHeader,
@@ -98,7 +98,7 @@ export default function LoginPage() {
}
if (isUserLoading || user) {
return <LoadingBox className="h-[80vh]" />;
return <Spinner className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -1,4 +1,8 @@
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";
@@ -61,11 +65,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="text-underline-position-from-font text-decoration-skip-none text-left font-poppins text-base font-medium leading-6">
<p className="font-geist text-underline-position-from-font text-decoration-skip-none text-left text-base font-medium leading-6">
About
</p>
<div
className="text-[48px] font-normal leading-[59px] text-neutral-900 dark:text-zinc-50"
className="font-poppins text-[48px] font-normal leading-[59px] text-neutral-900 dark:text-zinc-50"
style={{ whiteSpace: "pre-line" }}
>
{creator.description}
@@ -88,7 +92,9 @@ export default async function Page({
} catch (error) {
return (
<div className="flex h-screen w-full items-center justify-center">
<div className="text-2xl text-neutral-900">Creator not found</div>
<div className="font-neue text-2xl text-neutral-900">
Creator not found
</div>
</div>
);
}

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="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<h2 className="font-geist 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

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

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 LoadingBox from "@/components/ui/loading";
import Spinner from "@/components/Spinner";
import { getBehaveAs } from "@/lib/utils";
import { useTurnstile } from "@/hooks/useTurnstile";
@@ -134,7 +134,7 @@ export default function ResetPasswordPage() {
);
if (isUserLoading) {
return <LoadingBox className="h-[80vh]" />;
return <Spinner 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 LoadingBox from "@/components/ui/loading";
import Spinner from "@/components/Spinner";
import {
AuthCard,
AuthHeader,
@@ -94,7 +94,7 @@ export default function SignupPage() {
}
if (isUserLoading || user) {
return <LoadingBox className="h-[80vh]" />;
return <Spinner className="h-[80vh]" />;
}
if (!supabase) {

View File

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

View File

@@ -1,16 +1,20 @@
import React, { Suspense } from "react";
import type { Metadata } from "next";
import { Poppins } from "next/font/google";
import { Inter, 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"],
@@ -30,14 +34,19 @@ export default async function RootLayout({
return (
<html
lang="en"
className={`${poppins.variable} ${GeistSans.variable} ${GeistMono.variable}`}
className={`${poppins.variable} ${GeistSans.variable} ${GeistMono.variable} ${inter.variable}`}
>
<head>
<GoogleAnalytics
gaId={process.env.GA_MEASUREMENT_ID || "G-FH2XK2W4GN"} // This is the measurement Id for the Google Analytics dev project
/>
</head>
<body>
<body
className={cn(
"bg-neutral-50 antialiased transition-colors",
inter.className,
)}
>
<Providers
attribute="class"
defaultTheme="light"
@@ -48,6 +57,9 @@ 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,12 +1,11 @@
"use client";
import React, {
createContext,
useState,
useCallback,
useEffect,
useRef,
MouseEvent,
Suspense,
createContext,
} from "react";
import {
ReactFlow,
@@ -49,7 +48,6 @@ 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";
@@ -678,7 +676,7 @@ const FlowEditor: React.FC<{
<Controls />
<Background className="dark:bg-slate-800" />
<ControlPanel
className="absolute z-20"
className="absolute z-10"
controls={editorControls}
topChildren={
<BlocksControl
@@ -703,7 +701,6 @@ 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) {
@@ -743,12 +740,6 @@ 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,30 +1,32 @@
"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 type { GraphID } from "@/lib/autogpt-server-api/types";
import { 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;
}
export default function OttoChatWidget({
graphID,
className,
}: {
graphID?: GraphID;
className?: string;
}): React.ReactNode {
const OttoChatWidget = () => {
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
@@ -32,7 +34,7 @@ export default function OttoChatWidget({
setMessages([
{
type: "assistant",
content: "Hello, I am Otto! Ask me anything about AutoGPT!",
content: "Hello im Otto! Ask me anything about AutoGPT!",
},
]);
}
@@ -82,7 +84,7 @@ export default function OttoChatWidget({
userMessage,
conversationHistory,
includeGraphData,
graphID,
flowID || undefined,
);
// Check if the response contains an error
@@ -129,13 +131,13 @@ export default function 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") {
if (process.env.NEXT_PUBLIC_BEHAVE_AS !== "CLOUD" || pathname !== "/build") {
return null;
}
if (!isOpen) {
return (
<div className={className}>
<div className="fixed bottom-4 right-4 z-50">
<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"
@@ -158,13 +160,7 @@ export default function OttoChatWidget({
}
return (
<div
className={cn(
"flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl",
className,
"z-40",
)}
>
<div className="fixed bottom-4 right-4 z-50 flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b p-4">
<h2 className="font-semibold">Otto Assistant</h2>
@@ -273,7 +269,7 @@ export default function OttoChatWidget({
Send
</button>
</div>
{graphID && (
{nodes && edges && (
<button
type="button"
onClick={() => {
@@ -307,4 +303,6 @@ export default function OttoChatWidget({
</form>
</div>
);
}
};
export default OttoChatWidget;

View File

@@ -1,14 +1,13 @@
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 React, { useState } from "react";
import { Button } from "./ui/button";
import { Clock, LogOut, ChevronLeft } 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;
@@ -19,7 +18,6 @@ interface PrimaryActionBarProps {
isScheduling: boolean;
requestStopRun: () => void;
runAgentTooltip: string;
className?: string;
}
const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
@@ -31,7 +29,6 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
isScheduling,
requestStopRun,
runAgentTooltip,
className,
}) => {
const runButtonLabel = !isRunning ? "Run" : "Stop";
@@ -40,13 +37,8 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
const runButtonOnClick = !isRunning ? onClickRunAgent : requestStopRun;
return (
<div
className={cn(
"flex w-fit select-none items-center justify-center p-4",
className,
)}
>
<div className="flex gap-1 md:gap-4">
<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`}>
<Tooltip key="ViewOutputs" delayDuration={500}>
<TooltipTrigger asChild>
<Button

View File

@@ -0,0 +1,11 @@
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-20 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-50 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-sans text-lg font-medium leading-6"
className="mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left font-inter text-lg font-medium leading-6"
>
Tutorial
</Button>

View File

@@ -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/(platform)/admin/spending/actions";
import { addDollars } from "@/app/admin/spending/actions";
import useCredits from "@/hooks/useCredits";
export function AdminAddMoneyButton({
@@ -99,6 +99,7 @@ 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/(platform)/admin/spending/actions";
import { getUsersTransactionHistory } from "@/app/admin/spending/actions";
import { AdminAddMoneyButton } from "./add-money-button";
import { CreditTransactionType } from "@/lib/autogpt-server-api";

View File

@@ -17,7 +17,6 @@ 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 {
@@ -134,8 +133,7 @@ export default function AgentRunDetailsView({
| null
| undefined = useMemo(() => {
if (!("outputs" in run)) return undefined;
if (!["running", "success", "failed", "stopped"].includes(runStatus))
return null;
if (!["running", "success", "failed"].includes(runStatus)) return null;
// Add type info from agent input schema
return Object.fromEntries(
@@ -253,7 +251,7 @@ export default function AgentRunDetailsView({
),
)
) : (
<LoadingBox spinnerSize={12} className="h-24" />
<p>Loading...</p>
)}
</CardContent>
</Card>
@@ -272,7 +270,7 @@ export default function AgentRunDetailsView({
</div>
))
) : (
<LoadingBox spinnerSize={12} className="h-24" />
<p>Loading...</p>
)}
</CardContent>
</Card>

View File

@@ -13,7 +13,6 @@ 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({
@@ -114,7 +113,7 @@ export default function AgentScheduleDetailsView({
</div>
))
) : (
<LoadingBox spinnerSize={12} className="h-24" />
<p>Loading...</p>
)}
</CardContent>
</Card>

View File

@@ -99,7 +99,7 @@ export const AgentImageItem: React.FC<AgentImageItemProps> = React.memo(
}
}}
>
<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">
<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">
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="text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
<div className="font-sans 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="text-base font-medium text-neutral-800 hover:underline dark:text-neutral-200 sm:text-lg lg:text-xl"
className="font-sans 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 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 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">
{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 text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
<span className="whitespace-nowrap font-sans 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 text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
<div className="whitespace-nowrap font-sans 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="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
<div className="mb-1.5 font-sans text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Description
</div>
<div className="whitespace-pre-line text-base font-normal leading-6 text-neutral-600 dark:text-neutral-400">
<div className="whitespace-pre-line font-sans 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 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 font-sans 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 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 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]"
>
{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 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 font-sans 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 text-base font-normal leading-6 text-neutral-600 underline-offset-[from-font] dark:text-neutral-400">
<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">
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="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="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">
{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 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 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">
{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-sans 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-neue 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="text-sm font-normal leading-normal text-neutral-600 dark:text-neutral-400">
<p className="font-geist text-sm font-normal leading-normal text-neutral-600 dark:text-neutral-400">
{bio}
</p>
<div className="text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
<div className="font-geist 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="w-full text-lg font-normal leading-6 text-neutral-800 dark:text-neutral-200 sm:text-xl sm:leading-7">
<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">
@{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 text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="w-full font-neue 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="text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
<div className="font-neue 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 text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="w-full font-neue 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="text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
<div className="font-geist 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 text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Number of runs
</div>
<div className="text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
<div className="font-geist 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="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="font-neue 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="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
<div className="font-neue 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="text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
<div className="font-neue 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-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]"}`}
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]"}`}
>
{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-sans text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf]">
<div className="absolute left-0 top-6 font-inter 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-sans 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-inter 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="text-sm font-normal text-neutral-600 dark:text-neutral-400">
<p className="font-geist 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="text-xs font-normal leading-[14px] text-neutral-500 dark:text-neutral-400 sm:text-sm">
<small className="font-geist 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="text-base text-neutral-800 dark:text-neutral-200">
<span className="font-geist text-base text-neutral-800 dark:text-neutral-200">
Sort by
</span>
<span className="text-base text-neutral-800 dark:text-neutral-200">
<span className="font-geist 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 text-base font-normal leading-normal text-neutral-600 dark:text-neutral-400">
<p className="line-clamp-3 font-sans 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="text-lg font-semibold text-neutral-800 dark:text-neutral-200">
<div className="font-sans text-lg font-semibold text-neutral-800 dark:text-neutral-200">
{runs.toLocaleString()} runs
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-neutral-800 dark:text-neutral-200">
<span className="font-sans 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 text-sm font-semibold text-violet-700">
<div className="flex items-center font-inter 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 = "24px",
margin = "37px",
}) => {
const router = useRouter();
@@ -48,12 +48,11 @@ export const AgentsSection: React.FC<AgentsSectionProps> = ({
return (
<div className="flex flex-col items-center justify-center">
<div className="w-full max-w-[1360px]">
<h2
style={{ marginBottom: margin }}
className="font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200"
<div
className={`mb-[${margin}] font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200`}
>
{sectionTitle}
</h2>
</div>
{!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 md:flex">
<div className="mb-[32px] hidden items-start justify-between bg-neutral-50 md:flex">
{/* <LibraryNotificationDropdown /> */}
<LibrarySearchBar />
<LibraryUploadAgentDialog />
</div>
{/* Mobile and tablet */}
<div className="flex flex-col gap-4 p-4 pt-[52px] md:hidden">
<div className="flex flex-col gap-4 bg-neutral-50 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="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
className="font-geist 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="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
className="font-geist text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
>
Open in builder
</Link>

View File

@@ -34,7 +34,7 @@ export default function LibrarySortMenu(): React.ReactNode {
<Select onValueChange={handleSortChange}>
<SelectTrigger className="ml-1 w-fit space-x-1 border-none px-0 text-base underline underline-offset-4 shadow-none">
<ArrowDownNarrowWideIcon className="h-4 w-4 sm:hidden" />
<SelectValue placeholder="Last Ran" />
<SelectValue placeholder="Last Modified" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -44,9 +44,6 @@ export default function LibrarySortMenu(): React.ReactNode {
<SelectItem value={LibraryAgentSortEnum.UPDATED_AT}>
Last Modified
</SelectItem>
<SelectItem value={LibraryAgentSortEnum.LAST_EXECUTED_AT}>
Last Ran
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -219,6 +219,7 @@ 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 && <LoadingSpinner className="mr-2 size-5" />}
{isLoading && <Spinner className="h-5 w-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</Link>
@@ -68,7 +68,7 @@ export default function OnboardingButton({
disabled={disabled}
className={buttonClasses}
>
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
{isLoading && <Spinner className="h-5 w-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</button>

View File

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

View File

@@ -1,26 +0,0 @@
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,18 +13,10 @@ 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 swipeThreshold={swipeThreshold}>
{toasts.map(
({ id, title, description, action, dismissable, ...props }) => (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
@@ -33,10 +25,10 @@ export function Toaster() {
)}
</div>
{action}
{dismissable !== false && <ToastClose />}
<ToastClose />
</Toast>
),
)}
);
})}
<ToastViewport />
</ToastProvider>
);

View File

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

View File

@@ -68,7 +68,6 @@ 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
@@ -940,69 +939,43 @@ 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> {
return (this.wsConnecting ??= new Promise(async (resolve, reject) => {
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) => {
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";
console.warn("WebSocket connection closed", event);
this._stopWSHeartbeat(); // Stop heartbeat when connection closes
this.wsConnecting = null;
this.wsOnDisconnectHandlers.forEach((handler) => handler());
// Attempt to reconnect after a delay
setTimeout(() => this.connectWebSocket().then(resolve), 1000);
setTimeout(() => this.connectWebSocket(), 1000);
};
this.webSocket.onerror = (error) => {
if (this.webSocket?.state == "connected") {
console.error("[BackendAPI] WebSocket error:", error);
}
console.error("WebSocket error:", error);
this._stopWSHeartbeat(); // Stop heartbeat on error
this.wsConnecting = null;
reject(error);
};
this.webSocket.onmessage = (event) => this._handleWSMessage(event);
} catch (error) {
console.error("[BackendAPI] Error connecting to WebSocket:", error);
console.error("Error connecting to WebSocket:", error);
reject(error);
}
}));
});
return this.wsConnecting;
}
disconnectWebSocket() {
@@ -1071,12 +1044,6 @@ export default class BackendAPI {
}
}
declare global {
interface WebSocket {
state: "connecting" | "connected" | "closed";
}
}
/* *** UTILITY TYPES *** */
type GraphCreateRequestBody = {

View File

@@ -397,7 +397,6 @@ export type LibraryAgent = {
creator_image_url: string;
status: AgentStatus;
updated_at: Date;
last_executed_at?: Date;
name: string;
description: string;
input_schema: BlockIOObjectSubSchema;
@@ -457,7 +456,6 @@ export interface CreateLibraryAgentPresetRequest {
export enum LibraryAgentSortEnum {
CREATED_AT = "createdAt",
UPDATED_AT = "updatedAt",
LAST_EXECUTED_AT = "lastExecutedAt",
}
/* *** CREDENTIALS *** */

View File

@@ -17,7 +17,9 @@ 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,6 +99,8 @@ 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 |
| Email | Detailed information about a single email (now includes `threadId`) |
| Emails | A list of email data for multiple emails |
| Error | An error message if something goes wrong during the process |
@@ -141,4 +141,64 @@ 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.
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.