Compare commits
4 Commits
abhi/folde
...
docs/deplo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e934df3c0c | ||
|
|
8d557d33e1 | ||
|
|
223df9d3da | ||
|
|
187ab04745 |
@@ -12,7 +12,6 @@ import backend.api.features.store.image_gen as store_image_gen
|
||||
import backend.api.features.store.media as store_media
|
||||
import backend.data.graph as graph_db
|
||||
import backend.data.integrations as integrations_db
|
||||
from backend.api.features.library.exceptions import FolderValidationError
|
||||
from backend.data.db import transaction
|
||||
from backend.data.execution import get_graph_execution
|
||||
from backend.data.graph import GraphSettings
|
||||
@@ -43,8 +42,6 @@ async def list_library_agents(
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
include_executions: bool = False,
|
||||
folder_id: Optional[str] = None,
|
||||
include_root_only: bool = False,
|
||||
) -> library_model.LibraryAgentResponse:
|
||||
"""
|
||||
Retrieves a paginated list of LibraryAgent records for a given user.
|
||||
@@ -55,8 +52,6 @@ async def list_library_agents(
|
||||
sort_by: Sorting field (createdAt, updatedAt, isFavorite, isCreatedByUser).
|
||||
page: Current page (1-indexed).
|
||||
page_size: Number of items per page.
|
||||
folder_id: Filter by folder ID. If provided, only returns agents in this folder.
|
||||
include_root_only: If True, only returns agents without a folder (root-level).
|
||||
include_executions: Whether to include execution data for status calculation.
|
||||
Defaults to False for performance (UI fetches status separately).
|
||||
Set to True when accurate status/metrics are needed (e.g., agent generator).
|
||||
@@ -87,13 +82,6 @@ async def list_library_agents(
|
||||
"isArchived": False,
|
||||
}
|
||||
|
||||
# Apply folder filter (skip when searching — search spans all folders)
|
||||
if folder_id is not None and not search_term:
|
||||
where_clause["folderId"] = folder_id
|
||||
elif include_root_only and not search_term:
|
||||
where_clause["folderId"] = None
|
||||
|
||||
# Build search filter if applicable
|
||||
if search_term:
|
||||
where_clause["OR"] = [
|
||||
{
|
||||
@@ -646,7 +634,6 @@ async def update_library_agent(
|
||||
is_archived: Optional[bool] = None,
|
||||
is_deleted: Optional[Literal[False]] = None,
|
||||
settings: Optional[GraphSettings] = None,
|
||||
folder_id: Optional[str] = None,
|
||||
) -> library_model.LibraryAgent:
|
||||
"""
|
||||
Updates the specified LibraryAgent record.
|
||||
@@ -659,7 +646,6 @@ async def update_library_agent(
|
||||
is_favorite: Whether this agent is marked as a favorite.
|
||||
is_archived: Whether this agent is archived.
|
||||
settings: User-specific settings for this library agent.
|
||||
folder_id: Folder ID to move agent to (empty string "" for root, None to skip).
|
||||
|
||||
Returns:
|
||||
The updated LibraryAgent.
|
||||
@@ -687,23 +673,13 @@ async def update_library_agent(
|
||||
)
|
||||
update_fields["isDeleted"] = is_deleted
|
||||
if settings is not None:
|
||||
update_fields["settings"] = SafeJson(settings.model_dump())
|
||||
if folder_id is not None:
|
||||
# Empty string means "move to root" (no folder)
|
||||
if folder_id == "":
|
||||
update_fields["folderId"] = None
|
||||
else:
|
||||
# Verify folder belongs to user
|
||||
folder = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": folder_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not folder:
|
||||
raise NotFoundError(f"Folder #{folder_id} not found")
|
||||
update_fields["folderId"] = folder_id
|
||||
existing_agent = await get_library_agent(id=library_agent_id, user_id=user_id)
|
||||
current_settings_dict = (
|
||||
existing_agent.settings.model_dump() if existing_agent.settings else {}
|
||||
)
|
||||
new_settings = settings.model_dump(exclude_unset=True)
|
||||
merged_settings = {**current_settings_dict, **new_settings}
|
||||
update_fields["settings"] = SafeJson(merged_settings)
|
||||
|
||||
try:
|
||||
# If graph_version is provided, update to that specific version
|
||||
@@ -942,816 +918,6 @@ async def add_store_agent_to_library(
|
||||
raise DatabaseError("Failed to add agent to library") from e
|
||||
|
||||
|
||||
##############################################
|
||||
############ Folder DB Functions #############
|
||||
##############################################
|
||||
|
||||
MAX_FOLDER_DEPTH = 5
|
||||
|
||||
|
||||
async def list_folders(
|
||||
user_id: str,
|
||||
parent_id: Optional[str] = None,
|
||||
include_counts: bool = True,
|
||||
) -> list[library_model.LibraryFolder]:
|
||||
"""
|
||||
Lists folders for a user, optionally filtered by parent.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user.
|
||||
parent_id: If provided, only returns folders with this parent.
|
||||
If None, returns root-level folders.
|
||||
include_counts: Whether to include agent and subfolder counts.
|
||||
|
||||
Returns:
|
||||
A list of LibraryFolder objects.
|
||||
"""
|
||||
logger.debug(f"Listing folders for user #{user_id}, parent_id={parent_id}")
|
||||
|
||||
try:
|
||||
where_clause: prisma.types.LibraryFolderWhereInput = {
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
"parentId": parent_id,
|
||||
}
|
||||
|
||||
folders = await prisma.models.LibraryFolder.prisma().find_many(
|
||||
where=where_clause,
|
||||
order={"createdAt": "asc"},
|
||||
include=(
|
||||
{
|
||||
"LibraryAgents": {"where": {"isDeleted": False}},
|
||||
"Children": {"where": {"isDeleted": False}},
|
||||
}
|
||||
if include_counts
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
result = []
|
||||
for folder in folders:
|
||||
agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0
|
||||
subfolder_count = len(folder.Children) if folder.Children else 0
|
||||
result.append(
|
||||
library_model.LibraryFolder.from_db(
|
||||
folder,
|
||||
agent_count=agent_count,
|
||||
subfolder_count=subfolder_count,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error listing folders: {e}")
|
||||
raise DatabaseError("Failed to list folders") from e
|
||||
|
||||
|
||||
async def get_folder_tree(
|
||||
user_id: str,
|
||||
) -> list[library_model.LibraryFolderTree]:
|
||||
"""
|
||||
Gets the full folder tree for a user.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user.
|
||||
|
||||
Returns:
|
||||
A list of LibraryFolderTree objects (root folders with nested children).
|
||||
"""
|
||||
logger.debug(f"Getting folder tree for user #{user_id}")
|
||||
|
||||
try:
|
||||
# Fetch all folders for the user
|
||||
all_folders = await prisma.models.LibraryFolder.prisma().find_many(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
},
|
||||
order={"createdAt": "asc"},
|
||||
include={
|
||||
"LibraryAgents": {"where": {"isDeleted": False}},
|
||||
"Children": {"where": {"isDeleted": False}},
|
||||
},
|
||||
)
|
||||
|
||||
# Build a map of folder ID to folder data
|
||||
folder_map: dict[str, library_model.LibraryFolderTree] = {}
|
||||
for folder in all_folders:
|
||||
agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0
|
||||
subfolder_count = len(folder.Children) if folder.Children else 0
|
||||
folder_map[folder.id] = library_model.LibraryFolderTree(
|
||||
**library_model.LibraryFolder.from_db(
|
||||
folder,
|
||||
agent_count=agent_count,
|
||||
subfolder_count=subfolder_count,
|
||||
).model_dump(),
|
||||
children=[],
|
||||
)
|
||||
|
||||
# Build the tree structure
|
||||
root_folders: list[library_model.LibraryFolderTree] = []
|
||||
for folder in all_folders:
|
||||
tree_folder = folder_map[folder.id]
|
||||
if folder.parentId and folder.parentId in folder_map:
|
||||
folder_map[folder.parentId].children.append(tree_folder)
|
||||
else:
|
||||
root_folders.append(tree_folder)
|
||||
|
||||
return root_folders
|
||||
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error getting folder tree: {e}")
|
||||
raise DatabaseError("Failed to get folder tree") from e
|
||||
|
||||
|
||||
async def get_folder(
|
||||
folder_id: str,
|
||||
user_id: str,
|
||||
) -> library_model.LibraryFolder:
|
||||
"""
|
||||
Gets a single folder by ID.
|
||||
|
||||
Args:
|
||||
folder_id: The ID of the folder.
|
||||
user_id: The ID of the user (for ownership verification).
|
||||
|
||||
Returns:
|
||||
The LibraryFolder object.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the folder doesn't exist or doesn't belong to the user.
|
||||
"""
|
||||
try:
|
||||
folder = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": folder_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
},
|
||||
include={
|
||||
"LibraryAgents": {"where": {"isDeleted": False}},
|
||||
"Children": {"where": {"isDeleted": False}},
|
||||
},
|
||||
)
|
||||
|
||||
if not folder:
|
||||
raise NotFoundError(f"Folder #{folder_id} not found")
|
||||
|
||||
agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0
|
||||
subfolder_count = len(folder.Children) if folder.Children else 0
|
||||
|
||||
return library_model.LibraryFolder.from_db(
|
||||
folder,
|
||||
agent_count=agent_count,
|
||||
subfolder_count=subfolder_count,
|
||||
)
|
||||
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error getting folder: {e}")
|
||||
raise DatabaseError("Failed to get folder") from e
|
||||
|
||||
|
||||
async def get_folder_depth(folder_id: str, user_id: str) -> int:
|
||||
"""
|
||||
Calculate the depth of a folder in the hierarchy (root=0).
|
||||
|
||||
Args:
|
||||
folder_id: The ID of the folder.
|
||||
user_id: The ID of the user.
|
||||
|
||||
Returns:
|
||||
The depth of the folder (0 for root-level folders).
|
||||
"""
|
||||
depth = 0
|
||||
current_id: str | None = folder_id
|
||||
|
||||
while current_id:
|
||||
folder = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": current_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not folder:
|
||||
break
|
||||
if folder.parentId:
|
||||
depth += 1
|
||||
current_id = folder.parentId
|
||||
else:
|
||||
break
|
||||
|
||||
return depth
|
||||
|
||||
|
||||
async def is_descendant_of(
|
||||
folder_id: str,
|
||||
potential_ancestor_id: str,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if folder_id is a descendant of potential_ancestor_id.
|
||||
|
||||
Args:
|
||||
folder_id: The ID of the folder to check.
|
||||
potential_ancestor_id: The ID of the potential ancestor.
|
||||
user_id: The ID of the user.
|
||||
|
||||
Returns:
|
||||
True if folder_id is a descendant of potential_ancestor_id.
|
||||
"""
|
||||
current_id: str | None = folder_id
|
||||
|
||||
while current_id:
|
||||
if current_id == potential_ancestor_id:
|
||||
return True
|
||||
|
||||
folder = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": current_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not folder or not folder.parentId:
|
||||
break
|
||||
current_id = folder.parentId
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def validate_folder_operation(
|
||||
folder_id: Optional[str],
|
||||
target_parent_id: Optional[str],
|
||||
user_id: str,
|
||||
max_depth: int = MAX_FOLDER_DEPTH,
|
||||
) -> None:
|
||||
"""
|
||||
Validate that a folder move/create operation is valid.
|
||||
|
||||
Args:
|
||||
folder_id: The ID of the folder being moved (None for create).
|
||||
target_parent_id: The target parent ID (None for root).
|
||||
user_id: The ID of the user.
|
||||
max_depth: Maximum allowed nesting depth.
|
||||
|
||||
Raises:
|
||||
FolderValidationError: If the operation is invalid.
|
||||
"""
|
||||
# Cannot move folder into itself
|
||||
if folder_id and folder_id == target_parent_id:
|
||||
raise FolderValidationError("Cannot move folder into itself")
|
||||
|
||||
# Check for circular reference
|
||||
if folder_id and target_parent_id:
|
||||
if await is_descendant_of(target_parent_id, folder_id, user_id):
|
||||
raise FolderValidationError("Cannot move folder into its own descendant")
|
||||
|
||||
# Check depth limit
|
||||
if target_parent_id:
|
||||
parent_depth = await get_folder_depth(target_parent_id, user_id)
|
||||
if parent_depth + 1 >= max_depth:
|
||||
raise FolderValidationError(
|
||||
f"Maximum folder nesting depth of {max_depth} exceeded"
|
||||
)
|
||||
|
||||
|
||||
async def create_folder(
|
||||
user_id: str,
|
||||
name: str,
|
||||
parent_id: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
) -> library_model.LibraryFolder:
|
||||
"""
|
||||
Creates a new folder for the user.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user.
|
||||
name: The folder name.
|
||||
parent_id: Optional parent folder ID.
|
||||
icon: Optional icon identifier.
|
||||
color: Optional hex color code.
|
||||
|
||||
Returns:
|
||||
The created LibraryFolder.
|
||||
|
||||
Raises:
|
||||
FolderValidationError: If validation fails.
|
||||
DatabaseError: If there's a database error.
|
||||
"""
|
||||
logger.debug(f"Creating folder '{name}' for user #{user_id}")
|
||||
|
||||
try:
|
||||
# Validate operation
|
||||
await validate_folder_operation(
|
||||
folder_id=None,
|
||||
target_parent_id=parent_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Verify parent exists if provided
|
||||
if parent_id:
|
||||
parent = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": parent_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not parent:
|
||||
raise NotFoundError(f"Parent folder #{parent_id} not found")
|
||||
|
||||
# Build data dict conditionally - don't include Parent key if no parent_id
|
||||
create_data: dict = {
|
||||
"name": name,
|
||||
"User": {"connect": {"id": user_id}},
|
||||
}
|
||||
if icon is not None:
|
||||
create_data["icon"] = icon
|
||||
if color is not None:
|
||||
create_data["color"] = color
|
||||
if parent_id:
|
||||
create_data["Parent"] = {"connect": {"id": parent_id}}
|
||||
|
||||
folder = await prisma.models.LibraryFolder.prisma().create(data=create_data)
|
||||
|
||||
return library_model.LibraryFolder.from_db(folder)
|
||||
|
||||
except prisma.errors.UniqueViolationError:
|
||||
raise FolderValidationError(
|
||||
"A folder with this name already exists in this location"
|
||||
)
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error creating folder: {e}")
|
||||
raise DatabaseError("Failed to create folder") from e
|
||||
|
||||
|
||||
async def create_folder_with_unique_name(
|
||||
user_id: str,
|
||||
base_name: str,
|
||||
parent_id: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
) -> library_model.LibraryFolder:
|
||||
"""
|
||||
Creates a folder, appending (2), (3), etc. if name exists.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user.
|
||||
base_name: The base folder name.
|
||||
parent_id: Optional parent folder ID.
|
||||
icon: Optional icon identifier.
|
||||
color: Optional hex color code.
|
||||
|
||||
Returns:
|
||||
The created LibraryFolder.
|
||||
"""
|
||||
name = base_name
|
||||
suffix = 1
|
||||
|
||||
while True:
|
||||
try:
|
||||
return await create_folder(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
parent_id=parent_id,
|
||||
icon=icon,
|
||||
color=color,
|
||||
)
|
||||
except FolderValidationError as e:
|
||||
if "already exists" in str(e):
|
||||
suffix += 1
|
||||
name = f"{base_name} ({suffix})"
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def update_folder(
|
||||
folder_id: str,
|
||||
user_id: str,
|
||||
name: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
) -> library_model.LibraryFolder:
|
||||
"""
|
||||
Updates a folder's properties.
|
||||
|
||||
Args:
|
||||
folder_id: The ID of the folder to update.
|
||||
user_id: The ID of the user.
|
||||
name: New folder name.
|
||||
icon: New icon identifier.
|
||||
color: New hex color code.
|
||||
|
||||
Returns:
|
||||
The updated LibraryFolder.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the folder doesn't exist.
|
||||
DatabaseError: If there's a database error.
|
||||
"""
|
||||
logger.debug(f"Updating folder #{folder_id} for user #{user_id}")
|
||||
|
||||
try:
|
||||
# Verify folder exists and belongs to user
|
||||
existing = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": folder_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not existing:
|
||||
raise NotFoundError(f"Folder #{folder_id} not found")
|
||||
|
||||
update_data: prisma.types.LibraryFolderUpdateInput = {}
|
||||
if name is not None:
|
||||
update_data["name"] = name
|
||||
if icon is not None:
|
||||
update_data["icon"] = icon
|
||||
if color is not None:
|
||||
update_data["color"] = color
|
||||
|
||||
if not update_data:
|
||||
return await get_folder(folder_id, user_id)
|
||||
|
||||
folder = await prisma.models.LibraryFolder.prisma().update(
|
||||
where={"id": folder_id},
|
||||
data=update_data,
|
||||
include={
|
||||
"LibraryAgents": {"where": {"isDeleted": False}},
|
||||
"Children": {"where": {"isDeleted": False}},
|
||||
},
|
||||
)
|
||||
|
||||
if not folder:
|
||||
raise NotFoundError(f"Folder #{folder_id} not found")
|
||||
|
||||
agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0
|
||||
subfolder_count = len(folder.Children) if folder.Children else 0
|
||||
|
||||
return library_model.LibraryFolder.from_db(
|
||||
folder,
|
||||
agent_count=agent_count,
|
||||
subfolder_count=subfolder_count,
|
||||
)
|
||||
|
||||
except prisma.errors.UniqueViolationError:
|
||||
raise FolderValidationError(
|
||||
"A folder with this name already exists in this location"
|
||||
)
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error updating folder: {e}")
|
||||
raise DatabaseError("Failed to update folder") from e
|
||||
|
||||
|
||||
async def move_folder(
|
||||
folder_id: str,
|
||||
user_id: str,
|
||||
target_parent_id: Optional[str],
|
||||
) -> library_model.LibraryFolder:
|
||||
"""
|
||||
Moves a folder to a new parent.
|
||||
|
||||
Args:
|
||||
folder_id: The ID of the folder to move.
|
||||
user_id: The ID of the user.
|
||||
target_parent_id: The target parent ID (None for root).
|
||||
|
||||
Returns:
|
||||
The moved LibraryFolder.
|
||||
|
||||
Raises:
|
||||
FolderValidationError: If the move is invalid.
|
||||
NotFoundError: If the folder doesn't exist.
|
||||
DatabaseError: If there's a database error.
|
||||
"""
|
||||
logger.debug(f"Moving folder #{folder_id} to parent #{target_parent_id}")
|
||||
|
||||
try:
|
||||
# Validate operation
|
||||
await validate_folder_operation(
|
||||
folder_id=folder_id,
|
||||
target_parent_id=target_parent_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Verify folder exists
|
||||
existing = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": folder_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not existing:
|
||||
raise NotFoundError(f"Folder #{folder_id} not found")
|
||||
|
||||
# Verify target parent exists if provided
|
||||
if target_parent_id:
|
||||
parent = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": target_parent_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not parent:
|
||||
raise NotFoundError(
|
||||
f"Target parent folder #{target_parent_id} not found"
|
||||
)
|
||||
|
||||
folder = await prisma.models.LibraryFolder.prisma().update(
|
||||
where={"id": folder_id},
|
||||
data={
|
||||
"parentId": target_parent_id,
|
||||
},
|
||||
include={
|
||||
"LibraryAgents": {"where": {"isDeleted": False}},
|
||||
"Children": {"where": {"isDeleted": False}},
|
||||
},
|
||||
)
|
||||
|
||||
if not folder:
|
||||
raise NotFoundError(f"Folder #{folder_id} not found")
|
||||
|
||||
agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0
|
||||
subfolder_count = len(folder.Children) if folder.Children else 0
|
||||
|
||||
return library_model.LibraryFolder.from_db(
|
||||
folder,
|
||||
agent_count=agent_count,
|
||||
subfolder_count=subfolder_count,
|
||||
)
|
||||
|
||||
except prisma.errors.UniqueViolationError:
|
||||
raise FolderValidationError(
|
||||
"A folder with this name already exists in this location"
|
||||
)
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error moving folder: {e}")
|
||||
raise DatabaseError("Failed to move folder") from e
|
||||
|
||||
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
user_id: str,
|
||||
soft_delete: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Deletes a folder and all its contents (cascade).
|
||||
|
||||
Args:
|
||||
folder_id: The ID of the folder to delete.
|
||||
user_id: The ID of the user.
|
||||
soft_delete: If True, soft-deletes; otherwise hard-deletes.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the folder doesn't exist.
|
||||
DatabaseError: If there's a database error.
|
||||
"""
|
||||
logger.debug(f"Deleting folder #{folder_id} for user #{user_id}")
|
||||
|
||||
try:
|
||||
# Verify folder exists
|
||||
existing = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": folder_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not existing:
|
||||
raise NotFoundError(f"Folder #{folder_id} not found")
|
||||
|
||||
# Collect all folder IDs (target + descendants) before the transaction
|
||||
async with transaction() as tx:
|
||||
descendant_ids = await _get_descendant_folder_ids(folder_id, user_id, tx)
|
||||
all_folder_ids = [folder_id] + descendant_ids
|
||||
|
||||
if soft_delete:
|
||||
# Clean up schedules/webhooks for each affected agent before
|
||||
# soft-deleting, matching what delete_library_agent() does.
|
||||
affected_agents = await prisma.models.LibraryAgent.prisma().find_many(
|
||||
where={
|
||||
"folderId": {"in": all_folder_ids},
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
},
|
||||
)
|
||||
|
||||
async def _cleanup_agent(agent: prisma.models.LibraryAgent) -> None:
|
||||
try:
|
||||
await _cleanup_schedules_for_graph(
|
||||
graph_id=agent.agentGraphId, user_id=user_id
|
||||
)
|
||||
await _cleanup_webhooks_for_graph(
|
||||
graph_id=agent.agentGraphId, user_id=user_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Cleanup failed for agent {agent.id} "
|
||||
f"(graph {agent.agentGraphId}): {e}"
|
||||
)
|
||||
|
||||
await asyncio.gather(*[_cleanup_agent(a) for a in affected_agents])
|
||||
|
||||
async with transaction() as tx:
|
||||
if soft_delete:
|
||||
# Soft-delete all agents in these folders
|
||||
await prisma.models.LibraryAgent.prisma(tx).update_many(
|
||||
where={
|
||||
"folderId": {"in": all_folder_ids},
|
||||
"userId": user_id,
|
||||
},
|
||||
data={"isDeleted": True},
|
||||
)
|
||||
|
||||
# Soft-delete all folders
|
||||
await prisma.models.LibraryFolder.prisma(tx).update_many(
|
||||
where={
|
||||
"id": {"in": all_folder_ids},
|
||||
"userId": user_id,
|
||||
},
|
||||
data={"isDeleted": True},
|
||||
)
|
||||
else:
|
||||
# Move agents to root (or could hard-delete them)
|
||||
await prisma.models.LibraryAgent.prisma(tx).update_many(
|
||||
where={
|
||||
"folderId": {"in": all_folder_ids},
|
||||
"userId": user_id,
|
||||
},
|
||||
data={"folderId": None},
|
||||
)
|
||||
|
||||
# Hard-delete folders (children first due to FK constraints)
|
||||
for fid in reversed(all_folder_ids):
|
||||
await prisma.models.LibraryFolder.prisma(tx).delete(
|
||||
where={"id": fid}
|
||||
)
|
||||
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error deleting folder: {e}")
|
||||
raise DatabaseError("Failed to delete folder") from e
|
||||
|
||||
|
||||
async def _get_descendant_folder_ids(
|
||||
folder_id: str,
|
||||
user_id: str,
|
||||
tx: Optional[prisma.Prisma] = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Recursively get all descendant folder IDs.
|
||||
|
||||
Args:
|
||||
folder_id: The ID of the parent folder.
|
||||
user_id: The ID of the user.
|
||||
tx: Optional transaction.
|
||||
|
||||
Returns:
|
||||
A list of descendant folder IDs.
|
||||
"""
|
||||
prisma_client = prisma.models.LibraryFolder.prisma(tx)
|
||||
children = await prisma_client.find_many(
|
||||
where={
|
||||
"parentId": folder_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
|
||||
result: list[str] = []
|
||||
for child in children:
|
||||
result.append(child.id)
|
||||
result.extend(await _get_descendant_folder_ids(child.id, user_id, tx))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def move_agent_to_folder(
|
||||
library_agent_id: str,
|
||||
folder_id: Optional[str],
|
||||
user_id: str,
|
||||
) -> library_model.LibraryAgent:
|
||||
"""
|
||||
Moves a library agent to a folder.
|
||||
|
||||
Args:
|
||||
library_agent_id: The ID of the library agent.
|
||||
folder_id: The target folder ID (None for root).
|
||||
user_id: The ID of the user.
|
||||
|
||||
Returns:
|
||||
The updated LibraryAgent.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the agent or folder doesn't exist.
|
||||
DatabaseError: If there's a database error.
|
||||
"""
|
||||
logger.debug(f"Moving agent #{library_agent_id} to folder #{folder_id}")
|
||||
|
||||
try:
|
||||
# Verify agent exists
|
||||
agent = await prisma.models.LibraryAgent.prisma().find_first(
|
||||
where={
|
||||
"id": library_agent_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not agent:
|
||||
raise NotFoundError(f"Library agent #{library_agent_id} not found")
|
||||
|
||||
# Verify folder exists if provided
|
||||
if folder_id:
|
||||
folder = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": folder_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not folder:
|
||||
raise NotFoundError(f"Folder #{folder_id} not found")
|
||||
|
||||
await prisma.models.LibraryAgent.prisma().update(
|
||||
where={"id": library_agent_id},
|
||||
data={"folderId": folder_id},
|
||||
)
|
||||
|
||||
return await get_library_agent(library_agent_id, user_id)
|
||||
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error moving agent to folder: {e}")
|
||||
raise DatabaseError("Failed to move agent to folder") from e
|
||||
|
||||
|
||||
async def bulk_move_agents_to_folder(
|
||||
agent_ids: list[str],
|
||||
folder_id: Optional[str],
|
||||
user_id: str,
|
||||
) -> list[library_model.LibraryAgent]:
|
||||
"""
|
||||
Moves multiple library agents to a folder.
|
||||
|
||||
Args:
|
||||
agent_ids: The IDs of the library agents.
|
||||
folder_id: The target folder ID (None for root).
|
||||
user_id: The ID of the user.
|
||||
|
||||
Returns:
|
||||
The updated LibraryAgents.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If any agent or the folder doesn't exist.
|
||||
DatabaseError: If there's a database error.
|
||||
"""
|
||||
logger.debug(f"Bulk moving {len(agent_ids)} agents to folder #{folder_id}")
|
||||
|
||||
try:
|
||||
# Verify folder exists if provided
|
||||
if folder_id:
|
||||
folder = await prisma.models.LibraryFolder.prisma().find_first(
|
||||
where={
|
||||
"id": folder_id,
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
}
|
||||
)
|
||||
if not folder:
|
||||
raise NotFoundError(f"Folder #{folder_id} not found")
|
||||
|
||||
# Update all agents
|
||||
await prisma.models.LibraryAgent.prisma().update_many(
|
||||
where={
|
||||
"id": {"in": agent_ids},
|
||||
"userId": user_id,
|
||||
"isDeleted": False,
|
||||
},
|
||||
data={"folderId": folder_id},
|
||||
)
|
||||
|
||||
# Fetch and return updated agents
|
||||
agents = await prisma.models.LibraryAgent.prisma().find_many(
|
||||
where={
|
||||
"id": {"in": agent_ids},
|
||||
"userId": user_id,
|
||||
},
|
||||
include=library_agent_include(
|
||||
user_id, include_nodes=False, include_executions=False
|
||||
),
|
||||
)
|
||||
|
||||
return [library_model.LibraryAgent.from_db(agent) for agent in agents]
|
||||
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error bulk moving agents to folder: {e}")
|
||||
raise DatabaseError("Failed to bulk move agents to folder") from e
|
||||
|
||||
|
||||
##############################################
|
||||
########### Presets DB Functions #############
|
||||
##############################################
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
class FolderValidationError(Exception):
|
||||
"""Raised when folder operations fail validation."""
|
||||
|
||||
pass
|
||||
@@ -26,95 +26,6 @@ class LibraryAgentStatus(str, Enum):
|
||||
ERROR = "ERROR"
|
||||
|
||||
|
||||
# === Folder Models ===
|
||||
|
||||
|
||||
class LibraryFolder(pydantic.BaseModel):
|
||||
"""Represents a folder for organizing library agents."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
icon: str | None = None
|
||||
color: str | None = None
|
||||
parent_id: str | None = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
agent_count: int = 0 # Direct agents in folder
|
||||
subfolder_count: int = 0 # Direct child folders
|
||||
|
||||
@staticmethod
|
||||
def from_db(
|
||||
folder: prisma.models.LibraryFolder,
|
||||
agent_count: int = 0,
|
||||
subfolder_count: int = 0,
|
||||
) -> "LibraryFolder":
|
||||
"""Factory method that constructs a LibraryFolder from a Prisma model."""
|
||||
return LibraryFolder(
|
||||
id=folder.id,
|
||||
user_id=folder.userId,
|
||||
name=folder.name,
|
||||
icon=folder.icon,
|
||||
color=folder.color,
|
||||
parent_id=folder.parentId,
|
||||
created_at=folder.createdAt,
|
||||
updated_at=folder.updatedAt,
|
||||
agent_count=agent_count,
|
||||
subfolder_count=subfolder_count,
|
||||
)
|
||||
|
||||
|
||||
class LibraryFolderTree(LibraryFolder):
|
||||
"""Folder with nested children for tree view."""
|
||||
|
||||
children: list["LibraryFolderTree"] = []
|
||||
|
||||
|
||||
class FolderCreateRequest(pydantic.BaseModel):
|
||||
"""Request model for creating a folder."""
|
||||
|
||||
name: str = pydantic.Field(..., min_length=1, max_length=100)
|
||||
icon: str | None = None
|
||||
color: str | None = pydantic.Field(
|
||||
None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code (#RRGGBB)"
|
||||
)
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class FolderUpdateRequest(pydantic.BaseModel):
|
||||
"""Request model for updating a folder."""
|
||||
|
||||
name: str | None = pydantic.Field(None, min_length=1, max_length=100)
|
||||
icon: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class FolderMoveRequest(pydantic.BaseModel):
|
||||
"""Request model for moving a folder to a new parent."""
|
||||
|
||||
target_parent_id: str | None = None # None = move to root
|
||||
|
||||
|
||||
class BulkMoveAgentsRequest(pydantic.BaseModel):
|
||||
"""Request model for moving multiple agents to a folder."""
|
||||
|
||||
agent_ids: list[str]
|
||||
folder_id: str | None = None # None = move to root
|
||||
|
||||
|
||||
class FolderListResponse(pydantic.BaseModel):
|
||||
"""Response schema for a list of folders."""
|
||||
|
||||
folders: list[LibraryFolder]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
class FolderTreeResponse(pydantic.BaseModel):
|
||||
"""Response schema for folder tree structure."""
|
||||
|
||||
tree: list[LibraryFolderTree]
|
||||
|
||||
|
||||
class MarketplaceListingCreator(pydantic.BaseModel):
|
||||
"""Creator information for a marketplace listing."""
|
||||
|
||||
@@ -209,9 +120,6 @@ class LibraryAgent(pydantic.BaseModel):
|
||||
can_access_graph: bool
|
||||
is_latest_version: bool
|
||||
is_favorite: bool
|
||||
folder_id: str | None = None
|
||||
folder_name: str | None = None # Denormalized for display
|
||||
|
||||
recommended_schedule_cron: str | None = None
|
||||
settings: GraphSettings = pydantic.Field(default_factory=GraphSettings)
|
||||
marketplace_listing: Optional["MarketplaceListing"] = None
|
||||
@@ -320,10 +228,6 @@ class LibraryAgent(pydantic.BaseModel):
|
||||
creator=creator_data,
|
||||
)
|
||||
|
||||
# Folder information
|
||||
folder_id = agent.folderId
|
||||
folder_name = agent.Folder.name if agent.Folder else None
|
||||
|
||||
return LibraryAgent(
|
||||
id=agent.id,
|
||||
graph_id=agent.agentGraphId,
|
||||
@@ -355,8 +259,6 @@ class LibraryAgent(pydantic.BaseModel):
|
||||
can_access_graph=can_access_graph,
|
||||
is_latest_version=is_latest_version,
|
||||
is_favorite=agent.isFavorite,
|
||||
folder_id=folder_id,
|
||||
folder_name=folder_name,
|
||||
recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron,
|
||||
settings=_parse_settings(agent.settings),
|
||||
marketplace_listing=marketplace_listing_data,
|
||||
@@ -568,7 +470,3 @@ class LibraryAgentUpdateRequest(pydantic.BaseModel):
|
||||
settings: Optional[GraphSettings] = pydantic.Field(
|
||||
default=None, description="User-specific settings for this library agent"
|
||||
)
|
||||
folder_id: Optional[str] = pydantic.Field(
|
||||
default=None,
|
||||
description="Folder ID to move agent to (empty string for root)",
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import fastapi
|
||||
|
||||
from .agents import router as agents_router
|
||||
from .folders import router as folders_router
|
||||
from .presets import router as presets_router
|
||||
|
||||
router = fastapi.APIRouter()
|
||||
|
||||
router.include_router(presets_router)
|
||||
router.include_router(folders_router)
|
||||
router.include_router(agents_router)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from typing import Literal, Optional
|
||||
|
||||
import autogpt_libs.auth as autogpt_auth_lib
|
||||
@@ -7,13 +6,10 @@ from fastapi.responses import Response
|
||||
from prisma.enums import OnboardingStep
|
||||
|
||||
from backend.data.onboarding import complete_onboarding_step
|
||||
from backend.util.exceptions import DatabaseError, NotFoundError
|
||||
|
||||
from .. import db as library_db
|
||||
from .. import model as library_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/agents",
|
||||
tags=["library", "private"],
|
||||
@@ -45,34 +41,17 @@ async def list_library_agents(
|
||||
ge=1,
|
||||
description="Number of agents per page (must be >= 1)",
|
||||
),
|
||||
folder_id: Optional[str] = Query(
|
||||
None,
|
||||
description="Filter by folder ID",
|
||||
),
|
||||
include_root_only: bool = Query(
|
||||
False,
|
||||
description="Only return agents without a folder (root-level agents)",
|
||||
),
|
||||
) -> library_model.LibraryAgentResponse:
|
||||
"""
|
||||
Get all agents in the user's library (both created and saved).
|
||||
"""
|
||||
try:
|
||||
return await library_db.list_library_agents(
|
||||
user_id=user_id,
|
||||
search_term=search_term,
|
||||
sort_by=sort_by,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
folder_id=folder_id,
|
||||
include_root_only=include_root_only,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not list library agents for user #{user_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e),
|
||||
) from e
|
||||
return await library_db.list_library_agents(
|
||||
user_id=user_id,
|
||||
search_term=search_term,
|
||||
sort_by=sort_by,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -181,36 +160,15 @@ async def update_library_agent(
|
||||
"""
|
||||
Update the library agent with the given fields.
|
||||
"""
|
||||
try:
|
||||
return await library_db.update_library_agent(
|
||||
library_agent_id=library_agent_id,
|
||||
user_id=user_id,
|
||||
auto_update_version=payload.auto_update_version,
|
||||
graph_version=payload.graph_version,
|
||||
is_favorite=payload.is_favorite,
|
||||
is_archived=payload.is_archived,
|
||||
settings=payload.settings,
|
||||
folder_id=payload.folder_id,
|
||||
)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error while updating library agent: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"message": "Internal server error", "hint": "Contact support"},
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error while updating library agent: {e}", exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"message": "Internal server error", "hint": "Contact support"},
|
||||
) from e
|
||||
return await library_db.update_library_agent(
|
||||
library_agent_id=library_agent_id,
|
||||
user_id=user_id,
|
||||
auto_update_version=payload.auto_update_version,
|
||||
graph_version=payload.graph_version,
|
||||
is_favorite=payload.is_favorite,
|
||||
is_archived=payload.is_archived,
|
||||
settings=payload.settings,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import autogpt_libs.auth as autogpt_auth_lib
|
||||
from fastapi import APIRouter, HTTPException, Query, Security, status
|
||||
from fastapi.responses import Response
|
||||
|
||||
from backend.util.exceptions import DatabaseError, NotFoundError
|
||||
|
||||
from .. import db as library_db
|
||||
from .. import model as library_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/folders",
|
||||
tags=["library", "folders", "private"],
|
||||
dependencies=[Security(autogpt_auth_lib.requires_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="List Library Folders",
|
||||
response_model=library_model.FolderListResponse,
|
||||
responses={
|
||||
200: {"description": "List of folders"},
|
||||
500: {"description": "Server error"},
|
||||
},
|
||||
)
|
||||
async def list_folders(
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
parent_id: Optional[str] = Query(
|
||||
None,
|
||||
description="Filter by parent folder ID. If not provided, returns root-level folders.",
|
||||
),
|
||||
include_counts: bool = Query(
|
||||
True,
|
||||
description="Include agent and subfolder counts",
|
||||
),
|
||||
) -> library_model.FolderListResponse:
|
||||
"""
|
||||
List folders for the authenticated user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
parent_id: Optional parent folder ID to filter by.
|
||||
include_counts: Whether to include agent and subfolder counts.
|
||||
|
||||
Returns:
|
||||
A FolderListResponse containing folders.
|
||||
"""
|
||||
try:
|
||||
folders = await library_db.list_folders(
|
||||
user_id=user_id,
|
||||
parent_id=parent_id,
|
||||
include_counts=include_counts,
|
||||
)
|
||||
return library_model.FolderListResponse(
|
||||
folders=folders,
|
||||
pagination=library_model.Pagination(
|
||||
total_items=len(folders),
|
||||
total_pages=1,
|
||||
current_page=1,
|
||||
page_size=len(folders),
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not list folders for user #{user_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error",
|
||||
) from e
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tree",
|
||||
summary="Get Folder Tree",
|
||||
response_model=library_model.FolderTreeResponse,
|
||||
responses={
|
||||
200: {"description": "Folder tree structure"},
|
||||
500: {"description": "Server error"},
|
||||
},
|
||||
)
|
||||
async def get_folder_tree(
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> library_model.FolderTreeResponse:
|
||||
"""
|
||||
Get the full folder tree for the authenticated user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
|
||||
Returns:
|
||||
A FolderTreeResponse containing the nested folder structure.
|
||||
"""
|
||||
try:
|
||||
tree = await library_db.get_folder_tree(user_id=user_id)
|
||||
return library_model.FolderTreeResponse(tree=tree)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not get folder tree for user #{user_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error",
|
||||
) from e
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{folder_id}",
|
||||
summary="Get Folder",
|
||||
response_model=library_model.LibraryFolder,
|
||||
responses={
|
||||
200: {"description": "Folder details"},
|
||||
404: {"description": "Folder not found"},
|
||||
500: {"description": "Server error"},
|
||||
},
|
||||
)
|
||||
async def get_folder(
|
||||
folder_id: str,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> library_model.LibraryFolder:
|
||||
"""
|
||||
Get a specific folder.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the folder to retrieve.
|
||||
user_id: ID of the authenticated user.
|
||||
|
||||
Returns:
|
||||
The requested LibraryFolder.
|
||||
"""
|
||||
try:
|
||||
return await library_db.get_folder(folder_id=folder_id, user_id=user_id)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Could not get folder #{folder_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error",
|
||||
) from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
summary="Create Folder",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=library_model.LibraryFolder,
|
||||
responses={
|
||||
201: {"description": "Folder created successfully"},
|
||||
400: {"description": "Validation error"},
|
||||
404: {"description": "Parent folder not found"},
|
||||
409: {"description": "Folder name conflict"},
|
||||
500: {"description": "Server error"},
|
||||
},
|
||||
)
|
||||
async def create_folder(
|
||||
payload: library_model.FolderCreateRequest,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> library_model.LibraryFolder:
|
||||
"""
|
||||
Create a new folder.
|
||||
|
||||
Args:
|
||||
payload: The folder creation request.
|
||||
user_id: ID of the authenticated user.
|
||||
|
||||
Returns:
|
||||
The created LibraryFolder.
|
||||
"""
|
||||
try:
|
||||
return await library_db.create_folder(
|
||||
user_id=user_id,
|
||||
name=payload.name,
|
||||
parent_id=payload.parent_id,
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
)
|
||||
except library_db.FolderValidationError as e:
|
||||
if "already exists" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(e),
|
||||
) from e
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error creating folder: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error",
|
||||
) from e
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{folder_id}",
|
||||
summary="Update Folder",
|
||||
response_model=library_model.LibraryFolder,
|
||||
responses={
|
||||
200: {"description": "Folder updated successfully"},
|
||||
400: {"description": "Validation error"},
|
||||
404: {"description": "Folder not found"},
|
||||
409: {"description": "Folder name conflict"},
|
||||
500: {"description": "Server error"},
|
||||
},
|
||||
)
|
||||
async def update_folder(
|
||||
folder_id: str,
|
||||
payload: library_model.FolderUpdateRequest,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> library_model.LibraryFolder:
|
||||
"""
|
||||
Update a folder's properties.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the folder to update.
|
||||
payload: The folder update request.
|
||||
user_id: ID of the authenticated user.
|
||||
|
||||
Returns:
|
||||
The updated LibraryFolder.
|
||||
"""
|
||||
try:
|
||||
return await library_db.update_folder(
|
||||
folder_id=folder_id,
|
||||
user_id=user_id,
|
||||
name=payload.name,
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
)
|
||||
except library_db.FolderValidationError as e:
|
||||
if "already exists" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(e),
|
||||
) from e
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error updating folder #{folder_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error",
|
||||
) from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{folder_id}/move",
|
||||
summary="Move Folder",
|
||||
response_model=library_model.LibraryFolder,
|
||||
responses={
|
||||
200: {"description": "Folder moved successfully"},
|
||||
400: {"description": "Validation error (circular reference, depth exceeded)"},
|
||||
404: {"description": "Folder or target parent not found"},
|
||||
409: {"description": "Folder name conflict in target location"},
|
||||
500: {"description": "Server error"},
|
||||
},
|
||||
)
|
||||
async def move_folder(
|
||||
folder_id: str,
|
||||
payload: library_model.FolderMoveRequest,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> library_model.LibraryFolder:
|
||||
"""
|
||||
Move a folder to a new parent.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the folder to move.
|
||||
payload: The move request with target parent.
|
||||
user_id: ID of the authenticated user.
|
||||
|
||||
Returns:
|
||||
The moved LibraryFolder.
|
||||
"""
|
||||
try:
|
||||
return await library_db.move_folder(
|
||||
folder_id=folder_id,
|
||||
user_id=user_id,
|
||||
target_parent_id=payload.target_parent_id,
|
||||
)
|
||||
except library_db.FolderValidationError as e:
|
||||
if "already exists" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(e),
|
||||
) from e
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error moving folder #{folder_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error",
|
||||
) from e
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{folder_id}",
|
||||
summary="Delete Folder",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
responses={
|
||||
204: {"description": "Folder deleted successfully"},
|
||||
404: {"description": "Folder not found"},
|
||||
500: {"description": "Server error"},
|
||||
},
|
||||
)
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> Response:
|
||||
"""
|
||||
Soft-delete a folder and all its contents.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the folder to delete.
|
||||
user_id: ID of the authenticated user.
|
||||
|
||||
Returns:
|
||||
204 No Content if successful.
|
||||
"""
|
||||
try:
|
||||
await library_db.delete_folder(
|
||||
folder_id=folder_id,
|
||||
user_id=user_id,
|
||||
soft_delete=True,
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error deleting folder #{folder_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error",
|
||||
) from e
|
||||
|
||||
|
||||
# === Bulk Agent Operations ===
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agents/bulk-move",
|
||||
summary="Bulk Move Agents",
|
||||
response_model=list[library_model.LibraryAgent],
|
||||
responses={
|
||||
200: {"description": "Agents moved successfully"},
|
||||
404: {"description": "Folder not found"},
|
||||
500: {"description": "Server error"},
|
||||
},
|
||||
)
|
||||
async def bulk_move_agents(
|
||||
payload: library_model.BulkMoveAgentsRequest,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> list[library_model.LibraryAgent]:
|
||||
"""
|
||||
Move multiple agents to a folder.
|
||||
|
||||
Args:
|
||||
payload: The bulk move request with agent IDs and target folder.
|
||||
user_id: ID of the authenticated user.
|
||||
|
||||
Returns:
|
||||
The updated LibraryAgents.
|
||||
"""
|
||||
try:
|
||||
return await library_db.bulk_move_agents_to_folder(
|
||||
agent_ids=payload.agent_ids,
|
||||
folder_id=payload.folder_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error bulk moving agents: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error",
|
||||
) from e
|
||||
@@ -115,8 +115,6 @@ async def test_get_library_agents_success(
|
||||
sort_by=library_model.LibraryAgentSort.UPDATED_AT,
|
||||
page=1,
|
||||
page_size=15,
|
||||
folder_id=None,
|
||||
include_root_only=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -105,7 +105,6 @@ def library_agent_include(
|
||||
"""
|
||||
result: prisma.types.LibraryAgentInclude = {
|
||||
"Creator": True, # Always needed for creator info
|
||||
"Folder": True, # Always needed for folder info
|
||||
}
|
||||
|
||||
# Build AgentGraph include based on requested options
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `search` on the `StoreListingVersion` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "UnifiedContentEmbedding_search_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryAgent" ADD COLUMN "folderId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "StoreListingVersion" DROP COLUMN "search";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LibraryFolder" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"icon" TEXT,
|
||||
"color" TEXT,
|
||||
"parentId" TEXT,
|
||||
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "LibraryFolder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LibraryFolder_userId_isDeleted_idx" ON "LibraryFolder"("userId", "isDeleted");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LibraryFolder_parentId_idx" ON "LibraryFolder"("parentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LibraryFolder_userId_parentId_name_key" ON "LibraryFolder"("userId", "parentId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LibraryAgent_folderId_idx" ON "LibraryAgent"("folderId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "LibraryFolder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "LibraryFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -51,7 +51,6 @@ model User {
|
||||
ChatSessions ChatSession[]
|
||||
AgentPresets AgentPreset[]
|
||||
LibraryAgents LibraryAgent[]
|
||||
LibraryFolders LibraryFolder[]
|
||||
|
||||
Profile Profile[]
|
||||
UserOnboarding UserOnboarding?
|
||||
@@ -396,9 +395,6 @@ model LibraryAgent {
|
||||
creatorId String?
|
||||
Creator Profile? @relation(fields: [creatorId], references: [id])
|
||||
|
||||
folderId String?
|
||||
Folder LibraryFolder? @relation(fields: [folderId], references: [id], onDelete: Restrict)
|
||||
|
||||
useGraphIsActiveVersion Boolean @default(false)
|
||||
|
||||
isFavorite Boolean @default(false)
|
||||
@@ -411,32 +407,6 @@ model LibraryAgent {
|
||||
@@unique([userId, agentGraphId, agentGraphVersion])
|
||||
@@index([agentGraphId, agentGraphVersion])
|
||||
@@index([creatorId])
|
||||
@@index([folderId])
|
||||
}
|
||||
|
||||
model LibraryFolder {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
userId String
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String
|
||||
icon String?
|
||||
color String?
|
||||
|
||||
parentId String?
|
||||
Parent LibraryFolder? @relation("FolderHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
Children LibraryFolder[] @relation("FolderHierarchy")
|
||||
|
||||
isDeleted Boolean @default(false)
|
||||
|
||||
LibraryAgents LibraryAgent[]
|
||||
|
||||
@@unique([userId, parentId, name]) // Name unique per parent per user
|
||||
@@index([userId, isDeleted])
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -38,8 +38,6 @@
|
||||
"can_access_graph": true,
|
||||
"is_latest_version": true,
|
||||
"is_favorite": false,
|
||||
"folder_id": null,
|
||||
"folder_name": null,
|
||||
"recommended_schedule_cron": null,
|
||||
"settings": {
|
||||
"human_in_the_loop_safe_mode": true,
|
||||
@@ -85,8 +83,6 @@
|
||||
"can_access_graph": false,
|
||||
"is_latest_version": true,
|
||||
"is_favorite": false,
|
||||
"folder_id": null,
|
||||
"folder_name": null,
|
||||
"recommended_schedule_cron": null,
|
||||
"settings": {
|
||||
"human_in_the_loop_safe_mode": true,
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "3.0.61",
|
||||
"@faker-js/faker": "10.0.0",
|
||||
"@ferrucc-io/emoji-picker": "0.0.48",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@next/third-parties": "15.4.6",
|
||||
"@phosphor-icons/react": "2.1.10",
|
||||
|
||||
116
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -18,9 +18,6 @@ importers:
|
||||
'@faker-js/faker':
|
||||
specifier: 10.0.0
|
||||
version: 10.0.0
|
||||
'@ferrucc-io/emoji-picker':
|
||||
specifier: 0.0.48
|
||||
version: 0.0.48(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.17)
|
||||
'@hookform/resolvers':
|
||||
specifier: 5.2.2
|
||||
version: 5.2.2(react-hook-form@7.66.0(react@18.3.1))
|
||||
@@ -1510,14 +1507,6 @@ packages:
|
||||
resolution: {integrity: sha512-UollFEUkVXutsaP+Vndjxar40Gs5JL2HeLcl8xO1QAjJgOdhc3OmBFWyEylS+RddWaaBiAzH+5/17PLQJwDiLw==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
|
||||
|
||||
'@ferrucc-io/emoji-picker@0.0.48':
|
||||
resolution: {integrity: sha512-DJ5u+6VLF9OK7x+S/luwrVb5CHC6W16jL5b8vBUYNpxKWSuFgyliDHVtw1SGe6+dr5RUbf8WQwPJdKZmU3Ittg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18.2.0 || ^19.0.0
|
||||
react-dom: ^18.2.0 || ^19.0.0
|
||||
tailwindcss: '>=3.0.0'
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
@@ -3125,10 +3114,6 @@ packages:
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@sindresorhus/is@4.6.0':
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
@@ -3396,19 +3381,10 @@ packages:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/react-virtual@3.13.18':
|
||||
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.13.18':
|
||||
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4402,10 +4378,6 @@ packages:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
char-regex@1.0.2:
|
||||
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
character-entities-html4@2.1.0:
|
||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||
|
||||
@@ -5023,9 +4995,6 @@ packages:
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
emojilib@2.4.0:
|
||||
resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==}
|
||||
|
||||
emojis-list@3.0.0:
|
||||
resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -6006,24 +5975,6 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jotai@2.17.1:
|
||||
resolution: {integrity: sha512-TFNZZDa/0ewCLQyRC/Sq9crtixNj/Xdf/wmj9631xxMuKToVJZDbqcHIYN0OboH+7kh6P6tpIK7uKWClj86PKw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': '>=7.0.0'
|
||||
'@babel/template': '>=7.0.0'
|
||||
'@types/react': '>=17.0.0'
|
||||
react: '>=17.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
optional: true
|
||||
'@babel/template':
|
||||
optional: true
|
||||
'@types/react':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -6642,10 +6593,6 @@ packages:
|
||||
node-abort-controller@3.1.1:
|
||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||
|
||||
node-emoji@2.2.0:
|
||||
resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
node-fetch-h2@2.3.0:
|
||||
resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
@@ -7744,10 +7691,6 @@ packages:
|
||||
resolution: {integrity: sha512-LH7FpTAkeD+y5xQC4fzS+tFtaNlvt3Ib1zKzvhjv/Y+cioV4zIuw4IZr2yhRLu67CWL7FR9/6KXKnjRoZTvGGQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
skin-tone@2.0.0:
|
||||
resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
slash@3.0.0:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -8225,13 +8168,6 @@ packages:
|
||||
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
unicode-emoji-json@0.8.0:
|
||||
resolution: {integrity: sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==}
|
||||
|
||||
unicode-emoji-modifier-base@1.0.0:
|
||||
resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
unicode-match-property-ecmascript@2.0.0:
|
||||
resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -9841,22 +9777,6 @@ snapshots:
|
||||
|
||||
'@faker-js/faker@10.0.0': {}
|
||||
|
||||
'@ferrucc-io/emoji-picker@0.0.48(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.17)':
|
||||
dependencies:
|
||||
'@tanstack/react-virtual': 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
clsx: 2.1.1
|
||||
jotai: 2.17.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.17)(react@18.3.1)
|
||||
node-emoji: 2.2.0
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
tailwind-merge: 2.6.0
|
||||
tailwindcss: 3.4.17
|
||||
unicode-emoji-json: 0.8.0
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@babel/template'
|
||||
- '@types/react'
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
@@ -11618,8 +11538,6 @@ snapshots:
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@sindresorhus/is@4.6.0': {}
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
@@ -12093,16 +12011,8 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.18
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.18': {}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
@@ -13194,8 +13104,6 @@ snapshots:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
char-regex@1.0.2: {}
|
||||
|
||||
character-entities-html4@2.1.0: {}
|
||||
|
||||
character-entities-legacy@3.0.0: {}
|
||||
@@ -13839,8 +13747,6 @@ snapshots:
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
emojilib@2.4.0: {}
|
||||
|
||||
emojis-list@3.0.0: {}
|
||||
|
||||
endent@2.1.0:
|
||||
@@ -15122,13 +15028,6 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jotai@2.17.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.17)(react@18.3.1):
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/template': 7.27.2
|
||||
'@types/react': 18.3.17
|
||||
react: 18.3.1
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.0:
|
||||
@@ -15997,13 +15896,6 @@ snapshots:
|
||||
|
||||
node-abort-controller@3.1.1: {}
|
||||
|
||||
node-emoji@2.2.0:
|
||||
dependencies:
|
||||
'@sindresorhus/is': 4.6.0
|
||||
char-regex: 1.0.2
|
||||
emojilib: 2.4.0
|
||||
skin-tone: 2.0.0
|
||||
|
||||
node-fetch-h2@2.3.0:
|
||||
dependencies:
|
||||
http2-client: 1.3.5
|
||||
@@ -17304,10 +17196,6 @@ snapshots:
|
||||
dependencies:
|
||||
jsep: 1.4.0
|
||||
|
||||
skin-tone@2.0.0:
|
||||
dependencies:
|
||||
unicode-emoji-modifier-base: 1.0.0
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
@@ -17823,10 +17711,6 @@ snapshots:
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||
|
||||
unicode-emoji-json@0.8.0: {}
|
||||
|
||||
unicode-emoji-modifier-base@1.0.0: {}
|
||||
|
||||
unicode-match-property-ecmascript@2.0.0:
|
||||
dependencies:
|
||||
unicode-canonical-property-names-ecmascript: 2.0.1
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { GraphExecutionMeta } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/use-agent-runs";
|
||||
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { AgentRunDraftView } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view";
|
||||
import { AgentRunDraftView } from "@/app/(platform)/build/components/legacy-builder/agent-run-draft-view";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import type {
|
||||
CredentialsMetaInput,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getGetV2ListMySubmissionsQueryKey } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { CronExpressionDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog";
|
||||
import { CronExpressionDialog } from "@/components/contextual/CronScheduler/cron-scheduler-dialog";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { CalendarClockIcon } from "lucide-react";
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
import { RunAgentInputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs";
|
||||
import { ScheduleTaskDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog";
|
||||
import { ScheduleTaskDialog } from "@/components/contextual/CronScheduler/cron-scheduler-dialog";
|
||||
import ActionButtonGroup from "@/components/__legacy__/action-button-group";
|
||||
import type { ButtonAction } from "@/components/__legacy__/types";
|
||||
import {
|
||||
@@ -53,7 +53,10 @@ import { ClockIcon, CopyIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
|
||||
|
||||
import { analytics } from "@/services/analytics";
|
||||
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
|
||||
import {
|
||||
AgentStatus,
|
||||
AgentStatusChip,
|
||||
} from "@/app/(platform)/build/components/legacy-builder/agent-status-chip";
|
||||
|
||||
export function AgentRunDraftView({
|
||||
graph,
|
||||
@@ -4,11 +4,11 @@ import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
BookOpenIcon,
|
||||
CheckFatIcon,
|
||||
PencilSimpleIcon,
|
||||
WarningDiamondIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import Image from "next/image";
|
||||
import NextLink from "next/link";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ClarificationQuestionsCard,
|
||||
ClarifyingQuestion,
|
||||
} from "./components/ClarificationQuestionsCard";
|
||||
import sparklesImg from "./components/MiniGame/assets/sparkles.png";
|
||||
import { MiniGame } from "./components/MiniGame/MiniGame";
|
||||
import {
|
||||
AccordionIcon,
|
||||
@@ -83,7 +84,8 @@ function getAccordionMeta(output: CreateAgentToolOutput) {
|
||||
) {
|
||||
return {
|
||||
icon,
|
||||
title: "Creating agent, this may take a few minutes. Sit back and relax.",
|
||||
title:
|
||||
"Creating agent, this may take a few minutes. Play while you wait.",
|
||||
expanded: true,
|
||||
};
|
||||
}
|
||||
@@ -167,16 +169,20 @@ export function CreateAgentTool({ part }: Props) {
|
||||
{isAgentSavedOutput(output) && (
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<CheckFatIcon
|
||||
size={18}
|
||||
weight="regular"
|
||||
className="relative top-1 text-green-500"
|
||||
<Image
|
||||
src={sparklesImg}
|
||||
alt="sparkles"
|
||||
width={24}
|
||||
height={24}
|
||||
className="relative top-1"
|
||||
/>
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="text-blacks mb-2 text-[16px]"
|
||||
className="mb-2 text-[16px] text-black"
|
||||
>
|
||||
{output.message}
|
||||
Agent{" "}
|
||||
<span className="text-violet-600">{output.agent_name}</span>{" "}
|
||||
has been saved to your library!
|
||||
</Text>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-4">
|
||||
|
||||
@@ -2,20 +2,65 @@
|
||||
|
||||
import { useMiniGame } from "./useMiniGame";
|
||||
|
||||
function Key({ children }: { children: React.ReactNode }) {
|
||||
return <strong>[{children}]</strong>;
|
||||
}
|
||||
|
||||
export function MiniGame() {
|
||||
const { canvasRef } = useMiniGame();
|
||||
const { canvasRef, activeMode, showOverlay, score, highScore, onContinue } =
|
||||
useMiniGame();
|
||||
|
||||
const isRunActive =
|
||||
activeMode === "run" || activeMode === "idle" || activeMode === "over";
|
||||
|
||||
let overlayText: string | undefined;
|
||||
let buttonLabel = "Continue";
|
||||
if (activeMode === "idle") {
|
||||
buttonLabel = "Start";
|
||||
} else if (activeMode === "boss-intro") {
|
||||
overlayText = "Face the bandit!";
|
||||
} else if (activeMode === "boss-defeated") {
|
||||
overlayText = "Great job, keep on going";
|
||||
} else if (activeMode === "over") {
|
||||
overlayText = `Score: ${score} / Record: ${highScore}`;
|
||||
buttonLabel = "Retry";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-md bg-background text-foreground"
|
||||
style={{ border: "1px solid #d17fff" }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
className="block w-full outline-none"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-purple-500">
|
||||
{isRunActive ? (
|
||||
<>
|
||||
Run mode: <Key>Space</Key> to jump
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Duel mode: <Key>←→</Key> to move · <Key>Z</Key> to attack ·{" "}
|
||||
<Key>X</Key> to block · <Key>Space</Key> to jump
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="relative w-full overflow-hidden rounded-md border border-accent bg-background text-foreground">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
className="block w-full outline-none"
|
||||
/>
|
||||
{showOverlay && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/40">
|
||||
{overlayText && (
|
||||
<p className="text-lg font-bold text-white">{overlayText}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="rounded-md bg-white px-4 py-2 text-sm font-semibold text-zinc-800 shadow-md transition-colors hover:bg-zinc-100"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -136,7 +136,7 @@ export function getAnimationText(part: {
|
||||
if (isOperationPendingOutput(output)) return "Agent creation in progress";
|
||||
if (isOperationInProgressOutput(output))
|
||||
return "Agent creation already in progress";
|
||||
if (isAgentSavedOutput(output)) return `Saved "${output.agent_name}"`;
|
||||
if (isAgentSavedOutput(output)) return `Saved ${output.agent_name}`;
|
||||
if (isAgentPreviewOutput(output)) return `Preview "${output.agent_name}"`;
|
||||
if (isClarificationNeededOutput(output)) return "Needs clarification";
|
||||
return "Error creating agent";
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { ToolUIPart } from "ai";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import { ProgressBar } from "../../components/ProgressBar/ProgressBar";
|
||||
import {
|
||||
ContentCardDescription,
|
||||
ContentCodeBlock,
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { useAsymptoticProgress } from "../../hooks/useAsymptoticProgress";
|
||||
import { MiniGame } from "../CreateAgent/components/MiniGame/MiniGame";
|
||||
import {
|
||||
ClarificationQuestionsCard,
|
||||
ClarifyingQuestion,
|
||||
@@ -54,6 +53,7 @@ function getAccordionMeta(output: EditAgentToolOutput): {
|
||||
title: string;
|
||||
titleClassName?: string;
|
||||
description?: string;
|
||||
expanded?: boolean;
|
||||
} {
|
||||
const icon = <AccordionIcon />;
|
||||
|
||||
@@ -80,7 +80,11 @@ function getAccordionMeta(output: EditAgentToolOutput): {
|
||||
isOperationPendingOutput(output) ||
|
||||
isOperationInProgressOutput(output)
|
||||
) {
|
||||
return { icon: <OrbitLoader size={32} />, title: "Editing agent" };
|
||||
return {
|
||||
icon: <OrbitLoader size={32} />,
|
||||
title: "Editing agent, this may take a few minutes. Play while you wait.",
|
||||
expanded: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: (
|
||||
@@ -105,7 +109,6 @@ export function EditAgentTool({ part }: Props) {
|
||||
(isOperationStartedOutput(output) ||
|
||||
isOperationPendingOutput(output) ||
|
||||
isOperationInProgressOutput(output));
|
||||
const progress = useAsymptoticProgress(isOperating);
|
||||
const hasExpandableContent =
|
||||
part.state === "output-available" &&
|
||||
!!output &&
|
||||
@@ -149,9 +152,9 @@ export function EditAgentTool({ part }: Props) {
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isOperating && (
|
||||
<ContentGrid>
|
||||
<ProgressBar value={progress} className="max-w-[280px]" />
|
||||
<MiniGame />
|
||||
<ContentHint>
|
||||
This could take a few minutes, grab a coffee ☕
|
||||
This could take a few minutes — play while you wait!
|
||||
</ContentHint>
|
||||
</ContentGrid>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { ContentMessage } from "../../components/ToolAccordion/AccordionContent";
|
||||
import {
|
||||
ContentGrid,
|
||||
ContentHint,
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { MiniGame } from "../CreateAgent/components/MiniGame/MiniGame";
|
||||
import {
|
||||
getAccordionMeta,
|
||||
getAnimationText,
|
||||
@@ -60,6 +66,21 @@ export function RunAgentTool({ part }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isStreaming && !output && (
|
||||
<ToolAccordion
|
||||
icon={<OrbitLoader size={32} />}
|
||||
title="Running agent, this may take a few minutes. Play while you wait."
|
||||
expanded={true}
|
||||
>
|
||||
<ContentGrid>
|
||||
<MiniGame />
|
||||
<ContentHint>
|
||||
This could take a few minutes — play while you wait!
|
||||
</ContentHint>
|
||||
</ContentGrid>
|
||||
</ToolAccordion>
|
||||
)}
|
||||
|
||||
{hasExpandableContent && output && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isRunAgentExecutionStartedOutput(output) && (
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
"use client";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQueryState } from "nuqs";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
GraphExecution,
|
||||
GraphExecutionID,
|
||||
GraphExecutionMeta,
|
||||
GraphID,
|
||||
LibraryAgent,
|
||||
LibraryAgentID,
|
||||
LibraryAgentPreset,
|
||||
LibraryAgentPresetID,
|
||||
Schedule,
|
||||
ScheduleID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { exportAsJSONFile } from "@/lib/utils";
|
||||
|
||||
import DeleteConfirmDialog from "@/components/__legacy__/delete-confirm-dialog";
|
||||
import type { ButtonAction } from "@/components/__legacy__/types";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import LoadingBox, { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import {
|
||||
useToast,
|
||||
useToastOnFail,
|
||||
} from "@/components/molecules/Toast/use-toast";
|
||||
import { AgentRunDetailsView } from "./components/agent-run-details-view";
|
||||
import { AgentRunDraftView } from "./components/agent-run-draft-view";
|
||||
import { CreatePresetDialog } from "./components/create-preset-dialog";
|
||||
import { useAgentRunsInfinite } from "./use-agent-runs";
|
||||
import { AgentRunsSelectorList } from "./components/agent-runs-selector-list";
|
||||
import { AgentScheduleDetailsView } from "./components/agent-schedule-details-view";
|
||||
|
||||
export function OldAgentLibraryView() {
|
||||
const { id: agentID }: { id: LibraryAgentID } = useParams();
|
||||
const [executionId, setExecutionId] = useQueryState("executionId");
|
||||
const toastOnFail = useToastOnFail();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const api = useBackendAPI();
|
||||
|
||||
// ============================ STATE =============================
|
||||
|
||||
const [graph, setGraph] = useState<Graph | null>(null); // Graph version corresponding to LibraryAgent
|
||||
const [agent, setAgent] = useState<LibraryAgent | null>(null);
|
||||
const agentRunsQuery = useAgentRunsInfinite(graph?.id); // only runs once graph.id is known
|
||||
const agentRuns = agentRunsQuery.agentRuns;
|
||||
const [agentPresets, setAgentPresets] = useState<LibraryAgentPreset[]>([]);
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [selectedView, selectView] = useState<
|
||||
| { type: "run"; id?: GraphExecutionID }
|
||||
| { type: "preset"; id: LibraryAgentPresetID }
|
||||
| { type: "schedule"; id: ScheduleID }
|
||||
>({ type: "run" });
|
||||
const [selectedRun, setSelectedRun] = useState<
|
||||
GraphExecution | GraphExecutionMeta | null
|
||||
>(null);
|
||||
const selectedSchedule =
|
||||
selectedView.type == "schedule"
|
||||
? schedules.find((s) => s.id == selectedView.id)
|
||||
: null;
|
||||
const [isFirstLoad, setIsFirstLoad] = useState<boolean>(true);
|
||||
const [agentDeleteDialogOpen, setAgentDeleteDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [confirmingDeleteAgentRun, setConfirmingDeleteAgentRun] =
|
||||
useState<GraphExecutionMeta | null>(null);
|
||||
const [confirmingDeleteAgentPreset, setConfirmingDeleteAgentPreset] =
|
||||
useState<LibraryAgentPresetID | null>(null);
|
||||
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
|
||||
const [creatingPresetFromExecutionID, setCreatingPresetFromExecutionID] =
|
||||
useState<GraphExecutionID | null>(null);
|
||||
|
||||
// Set page title with agent name
|
||||
useEffect(() => {
|
||||
if (agent) {
|
||||
document.title = `${agent.name} - Library - AutoGPT Platform`;
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
const openRunDraftView = useCallback(() => {
|
||||
selectView({ type: "run" });
|
||||
}, []);
|
||||
|
||||
const selectRun = useCallback((id: GraphExecutionID) => {
|
||||
selectView({ type: "run", id });
|
||||
}, []);
|
||||
|
||||
const selectPreset = useCallback((id: LibraryAgentPresetID) => {
|
||||
selectView({ type: "preset", id });
|
||||
}, []);
|
||||
|
||||
const selectSchedule = useCallback((id: ScheduleID) => {
|
||||
selectView({ type: "schedule", id });
|
||||
}, []);
|
||||
|
||||
const graphVersions = useRef<Record<number, Graph>>({});
|
||||
const loadingGraphVersions = useRef<Record<number, Promise<Graph>>>({});
|
||||
const getGraphVersion = useCallback(
|
||||
async (graphID: GraphID, version: number) => {
|
||||
if (version in graphVersions.current)
|
||||
return graphVersions.current[version];
|
||||
if (version in loadingGraphVersions.current)
|
||||
return loadingGraphVersions.current[version];
|
||||
|
||||
const pendingGraph = api.getGraph(graphID, version).then((graph) => {
|
||||
graphVersions.current[version] = graph;
|
||||
return graph;
|
||||
});
|
||||
// Cache promise as well to avoid duplicate requests
|
||||
loadingGraphVersions.current[version] = pendingGraph;
|
||||
return pendingGraph;
|
||||
},
|
||||
[api, graphVersions, loadingGraphVersions],
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
getGraphVersion(agent.graph_id, agent.graph_version).then(
|
||||
(_graph) =>
|
||||
(graph && graph.version == _graph.version) || setGraph(_graph),
|
||||
);
|
||||
Promise.all([
|
||||
agentRunsQuery.refetchRuns(),
|
||||
api.listLibraryAgentPresets({
|
||||
graph_id: agent.graph_id,
|
||||
page_size: 100,
|
||||
}),
|
||||
]).then(([runsQueryResult, presets]) => {
|
||||
setAgentPresets(presets.presets);
|
||||
|
||||
const newestAgentRunsResponse = runsQueryResult.data?.pages[0];
|
||||
if (!newestAgentRunsResponse || newestAgentRunsResponse.status != 200)
|
||||
return;
|
||||
const newestAgentRuns = newestAgentRunsResponse.data.executions;
|
||||
// Preload the corresponding graph versions for the latest 10 runs
|
||||
new Set(
|
||||
newestAgentRuns.slice(0, 10).map((run) => run.graph_version),
|
||||
).forEach((version) => getGraphVersion(agent.graph_id, version));
|
||||
});
|
||||
});
|
||||
}, [api, agentID, getGraphVersion, graph]);
|
||||
|
||||
// On first load: select the latest run
|
||||
useEffect(() => {
|
||||
// Only for first load or first execution
|
||||
if (selectedView.id || !isFirstLoad) return;
|
||||
if (agentRuns.length == 0 && agentPresets.length == 0) return;
|
||||
|
||||
setIsFirstLoad(false);
|
||||
if (agentRuns.length > 0) {
|
||||
// select latest run
|
||||
const latestRun = agentRuns.reduce((latest, current) => {
|
||||
if (!latest.started_at && !current.started_at) return latest;
|
||||
if (!latest.started_at) return current;
|
||||
if (!current.started_at) return latest;
|
||||
return latest.started_at > current.started_at ? latest : current;
|
||||
}, agentRuns[0]);
|
||||
selectRun(latestRun.id as GraphExecutionID);
|
||||
} else {
|
||||
// select top preset
|
||||
const latestPreset = agentPresets.toSorted(
|
||||
(a, b) => b.updated_at.getTime() - a.updated_at.getTime(),
|
||||
)[0];
|
||||
selectPreset(latestPreset.id);
|
||||
}
|
||||
}, [
|
||||
isFirstLoad,
|
||||
selectedView.id,
|
||||
agentRuns,
|
||||
agentPresets,
|
||||
selectRun,
|
||||
selectPreset,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (executionId) {
|
||||
selectRun(executionId as GraphExecutionID);
|
||||
setExecutionId(null);
|
||||
}
|
||||
}, [executionId, selectRun, setExecutionId]);
|
||||
|
||||
// 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,
|
||||
dismissable: true,
|
||||
});
|
||||
});
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!agent?.graph_id) return;
|
||||
|
||||
return api.onWebSocketConnect(() => {
|
||||
refreshPageData(); // Sync up on (re)connect
|
||||
|
||||
// Subscribe to all executions for this agent
|
||||
api.subscribeToGraphExecutions(agent.graph_id);
|
||||
});
|
||||
}, [api, agent?.graph_id, refreshPageData]);
|
||||
|
||||
// Handle execution updates
|
||||
useEffect(() => {
|
||||
const detachExecUpdateHandler = api.onWebSocketMessage(
|
||||
"graph_execution_event",
|
||||
(data) => {
|
||||
if (data.graph_id != agent?.graph_id) return;
|
||||
|
||||
agentRunsQuery.upsertAgentRun(data);
|
||||
if (data.id === selectedView.id) {
|
||||
// Update currently viewed run
|
||||
setSelectedRun(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
detachExecUpdateHandler();
|
||||
};
|
||||
}, [api, agent?.graph_id, selectedView.id]);
|
||||
|
||||
// Pre-load selectedRun based on selectedView
|
||||
useEffect(() => {
|
||||
if (selectedView.type != "run" || !selectedView.id) return;
|
||||
|
||||
const newSelectedRun = agentRuns.find((run) => run.id == selectedView.id);
|
||||
if (selectedView.id !== selectedRun?.id) {
|
||||
// Pull partial data from "cache" while waiting for the rest to load
|
||||
setSelectedRun((newSelectedRun as GraphExecutionMeta) ?? null);
|
||||
}
|
||||
}, [api, selectedView, agentRuns, selectedRun?.id]);
|
||||
|
||||
// Load selectedRun based on selectedView; refresh on agent refresh
|
||||
useEffect(() => {
|
||||
if (selectedView.type != "run" || !selectedView.id || !agent) return;
|
||||
|
||||
api
|
||||
.getGraphExecutionInfo(agent.graph_id, selectedView.id)
|
||||
.then(async (run) => {
|
||||
// Ensure corresponding graph version is available before rendering I/O
|
||||
await getGraphVersion(run.graph_id, run.graph_version);
|
||||
setSelectedRun(run);
|
||||
});
|
||||
}, [api, selectedView, agent, getGraphVersion]);
|
||||
|
||||
const fetchSchedules = useCallback(async () => {
|
||||
if (!agent) return;
|
||||
|
||||
setSchedules(await api.listGraphExecutionSchedules(agent.graph_id));
|
||||
}, [api, agent?.graph_id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
}, [fetchSchedules]);
|
||||
|
||||
// =========================== ACTIONS ============================
|
||||
|
||||
const deleteRun = useCallback(
|
||||
async (run: GraphExecutionMeta) => {
|
||||
if (run.status == "RUNNING" || run.status == "QUEUED") {
|
||||
await api.stopGraphExecution(run.graph_id, run.id);
|
||||
}
|
||||
await api.deleteGraphExecution(run.id);
|
||||
|
||||
setConfirmingDeleteAgentRun(null);
|
||||
if (selectedView.type == "run" && selectedView.id == run.id) {
|
||||
openRunDraftView();
|
||||
}
|
||||
agentRunsQuery.removeAgentRun(run.id);
|
||||
},
|
||||
[api, selectedView, openRunDraftView],
|
||||
);
|
||||
|
||||
const deletePreset = useCallback(
|
||||
async (presetID: LibraryAgentPresetID) => {
|
||||
await api.deleteLibraryAgentPreset(presetID);
|
||||
|
||||
setConfirmingDeleteAgentPreset(null);
|
||||
if (selectedView.type == "preset" && selectedView.id == presetID) {
|
||||
openRunDraftView();
|
||||
}
|
||||
setAgentPresets((presets) => presets.filter((p) => p.id !== presetID));
|
||||
},
|
||||
[api, selectedView, openRunDraftView],
|
||||
);
|
||||
|
||||
const deleteSchedule = useCallback(
|
||||
async (scheduleID: ScheduleID) => {
|
||||
const removedSchedule =
|
||||
await api.deleteGraphExecutionSchedule(scheduleID);
|
||||
|
||||
setSchedules((schedules) => {
|
||||
const newSchedules = schedules.filter(
|
||||
(s) => s.id !== removedSchedule.id,
|
||||
);
|
||||
if (
|
||||
selectedView.type == "schedule" &&
|
||||
selectedView.id == removedSchedule.id
|
||||
) {
|
||||
if (newSchedules.length > 0) {
|
||||
// Select next schedule if available
|
||||
selectSchedule(newSchedules[0].id);
|
||||
} else {
|
||||
// Reset to draft view if current schedule was deleted
|
||||
openRunDraftView();
|
||||
}
|
||||
}
|
||||
return newSchedules;
|
||||
});
|
||||
openRunDraftView();
|
||||
},
|
||||
[schedules, api],
|
||||
);
|
||||
|
||||
const handleCreatePresetFromRun = useCallback(
|
||||
async (name: string, description: string) => {
|
||||
if (!creatingPresetFromExecutionID) return;
|
||||
|
||||
await api
|
||||
.createLibraryAgentPreset({
|
||||
name,
|
||||
description,
|
||||
graph_execution_id: creatingPresetFromExecutionID,
|
||||
})
|
||||
.then((preset) => {
|
||||
setAgentPresets((prev) => [...prev, preset]);
|
||||
selectPreset(preset.id);
|
||||
setCreatingPresetFromExecutionID(null);
|
||||
})
|
||||
.catch(toastOnFail("create a preset"));
|
||||
},
|
||||
[api, creatingPresetFromExecutionID, selectPreset, toast],
|
||||
);
|
||||
|
||||
const downloadGraph = useCallback(
|
||||
async () =>
|
||||
agent &&
|
||||
// Export sanitized graph from backend
|
||||
api
|
||||
.getGraph(agent.graph_id, agent.graph_version, true)
|
||||
.then((graph) =>
|
||||
exportAsJSONFile(graph, `${graph.name}_v${graph.version}.json`),
|
||||
),
|
||||
[api, agent],
|
||||
);
|
||||
|
||||
const copyAgent = useCallback(async () => {
|
||||
setCopyAgentDialogOpen(false);
|
||||
api
|
||||
.forkLibraryAgent(agentID)
|
||||
.then((newAgent) => {
|
||||
router.push(`/library/agents/${newAgent.id}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error copying agent:", error);
|
||||
toast({
|
||||
title: "Error copying agent",
|
||||
description: `An error occurred while copying the agent: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
}, [agentID, api, router, toast]);
|
||||
|
||||
const agentActions: ButtonAction[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Customize agent",
|
||||
href: `/build?flowID=${agent?.graph_id}&flowVersion=${agent?.graph_version}`,
|
||||
disabled: !agent?.can_access_graph,
|
||||
},
|
||||
{ label: "Export agent to file", callback: downloadGraph },
|
||||
...(!agent?.can_access_graph
|
||||
? [
|
||||
{
|
||||
label: "Edit a copy",
|
||||
callback: () => setCopyAgentDialogOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Delete agent",
|
||||
callback: () => setAgentDeleteDialogOpen(true),
|
||||
},
|
||||
],
|
||||
[agent, downloadGraph],
|
||||
);
|
||||
|
||||
const runGraph =
|
||||
graphVersions.current[selectedRun?.graph_version ?? 0] ?? graph;
|
||||
|
||||
const onCreateSchedule = useCallback(
|
||||
(schedule: Schedule) => {
|
||||
setSchedules((prev) => [...prev, schedule]);
|
||||
selectSchedule(schedule.id);
|
||||
},
|
||||
[selectView],
|
||||
);
|
||||
|
||||
const onCreatePreset = useCallback(
|
||||
(preset: LibraryAgentPreset) => {
|
||||
setAgentPresets((prev) => [...prev, preset]);
|
||||
selectPreset(preset.id);
|
||||
},
|
||||
[selectPreset],
|
||||
);
|
||||
|
||||
const onUpdatePreset = useCallback(
|
||||
(updated: LibraryAgentPreset) => {
|
||||
setAgentPresets((prev) =>
|
||||
prev.map((p) => (p.id === updated.id ? updated : p)),
|
||||
);
|
||||
selectPreset(updated.id);
|
||||
},
|
||||
[selectPreset],
|
||||
);
|
||||
|
||||
if (!agent || !graph) {
|
||||
return <LoadingBox className="h-[90vh]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container justify-stretch p-0 pt-16 lg:flex">
|
||||
{/* Sidebar w/ list of runs */}
|
||||
{/* TODO: render this below header in sm and md layouts */}
|
||||
<AgentRunsSelectorList
|
||||
className="agpt-div w-full border-b pb-2 lg:w-auto lg:border-b-0 lg:border-r lg:pb-0"
|
||||
agent={agent}
|
||||
agentRunsQuery={agentRunsQuery}
|
||||
agentPresets={agentPresets}
|
||||
schedules={schedules}
|
||||
selectedView={selectedView}
|
||||
onSelectRun={selectRun}
|
||||
onSelectPreset={selectPreset}
|
||||
onSelectSchedule={selectSchedule}
|
||||
onSelectDraftNewRun={openRunDraftView}
|
||||
doDeleteRun={setConfirmingDeleteAgentRun}
|
||||
doDeletePreset={setConfirmingDeleteAgentPreset}
|
||||
doDeleteSchedule={deleteSchedule}
|
||||
doCreatePresetFromRun={setCreatingPresetFromExecutionID}
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="agpt-div w-full border-b">
|
||||
<h1
|
||||
data-testid="agent-title"
|
||||
className="font-poppins text-3xl font-medium"
|
||||
>
|
||||
{
|
||||
agent.name /* TODO: use dynamic/custom run title - https://github.com/Significant-Gravitas/AutoGPT/issues/9184 */
|
||||
}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Run / Schedule views */}
|
||||
{(selectedView.type == "run" && selectedView.id ? (
|
||||
selectedRun && runGraph ? (
|
||||
<AgentRunDetailsView
|
||||
agent={agent}
|
||||
graph={runGraph}
|
||||
run={selectedRun}
|
||||
agentActions={agentActions}
|
||||
onRun={selectRun}
|
||||
doDeleteRun={() => setConfirmingDeleteAgentRun(selectedRun)}
|
||||
doCreatePresetFromRun={() =>
|
||||
setCreatingPresetFromExecutionID(selectedRun.id)
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
) : selectedView.type == "run" ? (
|
||||
/* Draft new runs / Create new presets */
|
||||
<AgentRunDraftView
|
||||
graph={graph}
|
||||
onRun={selectRun}
|
||||
onCreateSchedule={onCreateSchedule}
|
||||
onCreatePreset={onCreatePreset}
|
||||
agentActions={agentActions}
|
||||
recommendedScheduleCron={agent?.recommended_schedule_cron || null}
|
||||
/>
|
||||
) : selectedView.type == "preset" ? (
|
||||
/* Edit & update presets */
|
||||
<AgentRunDraftView
|
||||
graph={graph}
|
||||
agentPreset={
|
||||
agentPresets.find((preset) => preset.id == selectedView.id)!
|
||||
}
|
||||
onRun={selectRun}
|
||||
recommendedScheduleCron={agent?.recommended_schedule_cron || null}
|
||||
onCreateSchedule={onCreateSchedule}
|
||||
onUpdatePreset={onUpdatePreset}
|
||||
doDeletePreset={setConfirmingDeleteAgentPreset}
|
||||
agentActions={agentActions}
|
||||
/>
|
||||
) : selectedView.type == "schedule" ? (
|
||||
selectedSchedule &&
|
||||
graph && (
|
||||
<AgentScheduleDetailsView
|
||||
graph={graph}
|
||||
schedule={selectedSchedule}
|
||||
// agent={agent}
|
||||
agentActions={agentActions}
|
||||
onForcedRun={selectRun}
|
||||
doDeleteSchedule={deleteSchedule}
|
||||
/>
|
||||
)
|
||||
) : null) || <LoadingBox className="h-[70vh]" />}
|
||||
|
||||
<DeleteConfirmDialog
|
||||
entityType="agent"
|
||||
open={agentDeleteDialogOpen}
|
||||
onOpenChange={setAgentDeleteDialogOpen}
|
||||
onDoDelete={() =>
|
||||
agent &&
|
||||
api.deleteLibraryAgent(agent.id).then(() => router.push("/library"))
|
||||
}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
entityType="agent run"
|
||||
open={!!confirmingDeleteAgentRun}
|
||||
onOpenChange={(open) => !open && setConfirmingDeleteAgentRun(null)}
|
||||
onDoDelete={() =>
|
||||
confirmingDeleteAgentRun && deleteRun(confirmingDeleteAgentRun)
|
||||
}
|
||||
/>
|
||||
<DeleteConfirmDialog
|
||||
entityType={agent.has_external_trigger ? "trigger" : "agent preset"}
|
||||
open={!!confirmingDeleteAgentPreset}
|
||||
onOpenChange={(open) => !open && setConfirmingDeleteAgentPreset(null)}
|
||||
onDoDelete={() =>
|
||||
confirmingDeleteAgentPreset &&
|
||||
deletePreset(confirmingDeleteAgentPreset)
|
||||
}
|
||||
/>
|
||||
{/* Copy agent confirmation dialog */}
|
||||
<Dialog
|
||||
onOpenChange={setCopyAgentDialogOpen}
|
||||
open={copyAgentDialogOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>You're making an editable copy</DialogTitle>
|
||||
<DialogDescription className="pt-2">
|
||||
The original Marketplace agent stays the same and cannot be
|
||||
edited. We'll save a new version of this agent to your
|
||||
Library. From there, you can customize it however you'd
|
||||
like by clicking "Customize agent" — this will open
|
||||
the builder where you can see and modify the inner workings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCopyAgentDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={copyAgent}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CreatePresetDialog
|
||||
open={!!creatingPresetFromExecutionID}
|
||||
onOpenChange={() => setCreatingPresetFromExecutionID(null)}
|
||||
onConfirm={handleCreatePresetFromRun}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
"use client";
|
||||
import { format, formatDistanceToNow, formatDistanceStrict } from "date-fns";
|
||||
import React, { useCallback, useMemo, useEffect } from "react";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
GraphExecution,
|
||||
GraphExecutionID,
|
||||
GraphExecutionMeta,
|
||||
LibraryAgent,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
import ActionButtonGroup from "@/components/__legacy__/action-button-group";
|
||||
import type { ButtonAction } from "@/components/__legacy__/types";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconSquare,
|
||||
IconCircleAlert,
|
||||
} from "@/components/__legacy__/ui/icons";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
import { AgentRunStatus, agentRunStatusMap } from "./agent-run-status-chip";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { AgentRunOutputView } from "./agent-run-output-view";
|
||||
import { analytics } from "@/services/analytics";
|
||||
import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList";
|
||||
import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews";
|
||||
|
||||
export function AgentRunDetailsView({
|
||||
agent,
|
||||
graph,
|
||||
run,
|
||||
agentActions,
|
||||
onRun,
|
||||
doDeleteRun,
|
||||
doCreatePresetFromRun,
|
||||
}: {
|
||||
agent: LibraryAgent;
|
||||
graph: Graph;
|
||||
run: GraphExecution | GraphExecutionMeta;
|
||||
agentActions: ButtonAction[];
|
||||
onRun: (runID: GraphExecutionID) => void;
|
||||
doDeleteRun: () => void;
|
||||
doCreatePresetFromRun: () => void;
|
||||
}): React.ReactNode {
|
||||
const api = useBackendAPI();
|
||||
const { formatCredits } = useCredits();
|
||||
|
||||
const runStatus: AgentRunStatus = useMemo(
|
||||
() => agentRunStatusMap[run.status],
|
||||
[run],
|
||||
);
|
||||
|
||||
const {
|
||||
pendingReviews,
|
||||
isLoading: reviewsLoading,
|
||||
refetch: refetchReviews,
|
||||
} = usePendingReviewsForExecution(run.id);
|
||||
|
||||
const toastOnFail = useToastOnFail();
|
||||
|
||||
// Refetch pending reviews when execution status changes to REVIEW
|
||||
useEffect(() => {
|
||||
if (runStatus === "review" && run.id) {
|
||||
refetchReviews();
|
||||
}
|
||||
}, [runStatus, run.id, refetchReviews]);
|
||||
|
||||
const infoStats: { label: string; value: React.ReactNode }[] = useMemo(() => {
|
||||
if (!run) return [];
|
||||
return [
|
||||
{
|
||||
label: "Status",
|
||||
value: runStatus.charAt(0).toUpperCase() + runStatus.slice(1),
|
||||
},
|
||||
{
|
||||
label: "Started",
|
||||
value: run.started_at
|
||||
? `${formatDistanceToNow(run.started_at, { addSuffix: true })}, ${format(run.started_at, "HH:mm")}`
|
||||
: "—",
|
||||
},
|
||||
...(run.stats
|
||||
? [
|
||||
{
|
||||
label: "Duration",
|
||||
value: formatDistanceStrict(0, run.stats.duration * 1000),
|
||||
},
|
||||
{ label: "Steps", value: run.stats.node_exec_count },
|
||||
{ label: "Cost", value: formatCredits(run.stats.cost) },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}, [run, runStatus, formatCredits]);
|
||||
|
||||
const agentRunInputs:
|
||||
| Record<
|
||||
string,
|
||||
{
|
||||
title?: string;
|
||||
/* type: BlockIOSubType; */
|
||||
value: string | number | undefined;
|
||||
}
|
||||
>
|
||||
| undefined = useMemo(() => {
|
||||
if (!run.inputs) return undefined;
|
||||
// TODO: show (link to) preset - https://github.com/Significant-Gravitas/AutoGPT/issues/9168
|
||||
|
||||
// Add type info from agent input schema
|
||||
return Object.fromEntries(
|
||||
Object.entries(run.inputs).map(([k, v]) => [
|
||||
k,
|
||||
{
|
||||
title: graph.input_schema.properties[k]?.title,
|
||||
// type: graph.input_schema.properties[k].type, // TODO: implement typed graph inputs
|
||||
value: typeof v == "object" ? JSON.stringify(v, undefined, 2) : v,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}, [graph, run]);
|
||||
|
||||
const runAgain = useCallback(() => {
|
||||
if (
|
||||
!run.inputs ||
|
||||
!(graph.credentials_input_schema?.required ?? []).every(
|
||||
(k) => k in (run.credential_inputs ?? {}),
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
if (run.preset_id) {
|
||||
return api
|
||||
.executeLibraryAgentPreset(
|
||||
run.preset_id,
|
||||
run.inputs!,
|
||||
run.credential_inputs!,
|
||||
)
|
||||
.then(({ id }) => {
|
||||
analytics.sendDatafastEvent("run_agent", {
|
||||
name: graph.name,
|
||||
id: graph.id,
|
||||
});
|
||||
onRun(id);
|
||||
})
|
||||
.catch(toastOnFail("execute agent preset"));
|
||||
}
|
||||
|
||||
return api
|
||||
.executeGraph(
|
||||
graph.id,
|
||||
graph.version,
|
||||
run.inputs!,
|
||||
run.credential_inputs!,
|
||||
"library",
|
||||
)
|
||||
.then(({ id }) => {
|
||||
analytics.sendDatafastEvent("run_agent", {
|
||||
name: graph.name,
|
||||
id: graph.id,
|
||||
});
|
||||
onRun(id);
|
||||
})
|
||||
.catch(toastOnFail("execute agent"));
|
||||
}, [api, graph, run, onRun, toastOnFail]);
|
||||
|
||||
const stopRun = useCallback(
|
||||
() => api.stopGraphExecution(graph.id, run.id),
|
||||
[api, graph.id, run.id],
|
||||
);
|
||||
|
||||
const agentRunOutputs:
|
||||
| Record<
|
||||
string,
|
||||
{
|
||||
title?: string;
|
||||
/* type: BlockIOSubType; */
|
||||
values: Array<React.ReactNode>;
|
||||
}
|
||||
>
|
||||
| null
|
||||
| undefined = useMemo(() => {
|
||||
if (!("outputs" in run)) return undefined;
|
||||
if (!["running", "success", "failed", "stopped"].includes(runStatus))
|
||||
return null;
|
||||
|
||||
// Add type info from agent input schema
|
||||
return Object.fromEntries(
|
||||
Object.entries(run.outputs).map(([k, vv]) => [
|
||||
k,
|
||||
{
|
||||
title: graph.output_schema.properties[k].title,
|
||||
/* type: agent.output_schema.properties[k].type */
|
||||
values: vv.map((v) =>
|
||||
typeof v == "object" ? JSON.stringify(v, undefined, 2) : v,
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}, [graph, run, runStatus]);
|
||||
|
||||
const runActions: ButtonAction[] = useMemo(
|
||||
() => [
|
||||
...(["running", "queued"].includes(runStatus)
|
||||
? ([
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<IconSquare className="mr-2 size-4" />
|
||||
Stop run
|
||||
</>
|
||||
),
|
||||
variant: "secondary",
|
||||
callback: stopRun,
|
||||
},
|
||||
] satisfies ButtonAction[])
|
||||
: []),
|
||||
...(["success", "failed", "stopped"].includes(runStatus) &&
|
||||
!graph.has_external_trigger &&
|
||||
(graph.credentials_input_schema?.required ?? []).every(
|
||||
(k) => k in (run.credential_inputs ?? {}),
|
||||
)
|
||||
? [
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<IconRefresh className="mr-2 size-4" />
|
||||
Run again
|
||||
</>
|
||||
),
|
||||
callback: runAgain,
|
||||
dataTestId: "run-again-button",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(agent.can_access_graph
|
||||
? [
|
||||
{
|
||||
label: "Open run in builder",
|
||||
href: `/build?flowID=${run.graph_id}&flowVersion=${run.graph_version}&flowExecutionID=${run.id}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: "Create preset from run", callback: doCreatePresetFromRun },
|
||||
{ label: "Delete run", variant: "secondary", callback: doDeleteRun },
|
||||
],
|
||||
[
|
||||
runStatus,
|
||||
runAgain,
|
||||
stopRun,
|
||||
doDeleteRun,
|
||||
doCreatePresetFromRun,
|
||||
graph.has_external_trigger,
|
||||
graph.credentials_input_schema?.required,
|
||||
agent.can_access_graph,
|
||||
run.graph_id,
|
||||
run.graph_version,
|
||||
run.id,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="agpt-div flex gap-6">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Info</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex justify-stretch gap-4">
|
||||
{infoStats.map(({ label, value }) => (
|
||||
<div key={label} className="flex-1">
|
||||
<p className="text-sm font-medium text-black">{label}</p>
|
||||
<p className="text-sm text-neutral-600">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{run.status === "FAILED" && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
<strong>Error:</strong>{" "}
|
||||
{run.stats?.error ||
|
||||
"The execution failed due to an internal error. You can re-run the agent to retry."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Smart Agent Execution Summary */}
|
||||
{run.stats?.activity_status && (
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 font-poppins text-lg">
|
||||
Task Summary
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconCircleAlert className="size-4 cursor-help text-neutral-500 hover:text-neutral-700" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">
|
||||
This AI-generated summary describes how the agent
|
||||
handled your task. It’s an experimental feature and may
|
||||
occasionally be inaccurate.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm leading-relaxed text-neutral-700">
|
||||
{run.stats.activity_status}
|
||||
</p>
|
||||
|
||||
{/* Correctness Score */}
|
||||
{typeof run.stats.correctness_score === "number" && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-neutral-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-600">
|
||||
Success Estimate:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-2 w-16 overflow-hidden rounded-full bg-neutral-200">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
run.stats.correctness_score >= 0.8
|
||||
? "bg-green-500"
|
||||
: run.stats.correctness_score >= 0.6
|
||||
? "bg-yellow-500"
|
||||
: run.stats.correctness_score >= 0.4
|
||||
? "bg-orange-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.round(run.stats.correctness_score * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{Math.round(run.stats.correctness_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconCircleAlert className="size-4 cursor-help text-neutral-400 hover:text-neutral-600" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">
|
||||
AI-generated estimate of how well this execution
|
||||
achieved its intended purpose. This score indicates
|
||||
{run.stats.correctness_score >= 0.8
|
||||
? " the agent was highly successful."
|
||||
: run.stats.correctness_score >= 0.6
|
||||
? " the agent was mostly successful with minor issues."
|
||||
: run.stats.correctness_score >= 0.4
|
||||
? " the agent was partially successful with some gaps."
|
||||
: " the agent had limited success with significant issues."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{agentRunOutputs !== null && (
|
||||
<AgentRunOutputView agentRunOutputs={agentRunOutputs} />
|
||||
)}
|
||||
|
||||
{/* Pending Reviews Section */}
|
||||
{runStatus === "review" && (
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">
|
||||
Pending Reviews ({pendingReviews.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{reviewsLoading ? (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
) : pendingReviews.length > 0 ? (
|
||||
<PendingReviewsList
|
||||
reviews={pendingReviews}
|
||||
onReviewComplete={refetchReviews}
|
||||
emptyMessage="No pending reviews for this execution"
|
||||
/>
|
||||
) : (
|
||||
<div className="py-4 text-neutral-600">
|
||||
No pending reviews for this execution
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Input</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{agentRunInputs !== undefined ? (
|
||||
Object.entries(agentRunInputs).map(([key, { title, value }]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{title || key}</label>
|
||||
<Input value={value} className="rounded-full" disabled />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Run / Agent Actions */}
|
||||
<aside className="w-48 xl:w-56">
|
||||
<div className="flex flex-col gap-8">
|
||||
<ActionButtonGroup title="Run actions" actions={runActions} />
|
||||
|
||||
<ActionButtonGroup title="Agent actions" actions={agentActions} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
|
||||
import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
import type { OutputMetadata } from "../../../../../../../../components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "../../../../../../../../components/contextual/OutputRenderers";
|
||||
|
||||
export function AgentRunOutputView({
|
||||
agentRunOutputs,
|
||||
}: {
|
||||
agentRunOutputs:
|
||||
| Record<
|
||||
string,
|
||||
{
|
||||
title?: string;
|
||||
/* type: BlockIOSubType; */
|
||||
values: Array<React.ReactNode>;
|
||||
}
|
||||
>
|
||||
| undefined;
|
||||
}) {
|
||||
const enableEnhancedOutputHandling = useGetFlag(
|
||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
||||
);
|
||||
|
||||
// Prepare items for the renderer system
|
||||
const outputItems = useMemo(() => {
|
||||
if (!agentRunOutputs) return [];
|
||||
|
||||
const items: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
value: unknown;
|
||||
metadata?: OutputMetadata;
|
||||
renderer: any;
|
||||
}> = [];
|
||||
|
||||
Object.entries(agentRunOutputs).forEach(([key, { title, values }]) => {
|
||||
values.forEach((value, index) => {
|
||||
// Enhanced metadata extraction
|
||||
const metadata: OutputMetadata = {};
|
||||
|
||||
// Type guard to safely access properties
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!React.isValidElement(value)
|
||||
) {
|
||||
const objValue = value as any;
|
||||
if (objValue.type) metadata.type = objValue.type;
|
||||
if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
|
||||
if (objValue.filename) metadata.filename = objValue.filename;
|
||||
}
|
||||
|
||||
const renderer = globalRegistry.getRenderer(value, metadata);
|
||||
if (renderer) {
|
||||
items.push({
|
||||
key: `${key}-${index}`,
|
||||
label: index === 0 ? title || key : "",
|
||||
value,
|
||||
metadata,
|
||||
renderer,
|
||||
});
|
||||
} else {
|
||||
const textRenderer = globalRegistry
|
||||
.getAllRenderers()
|
||||
.find((r) => r.name === "TextRenderer");
|
||||
if (textRenderer) {
|
||||
items.push({
|
||||
key: `${key}-${index}`,
|
||||
label: index === 0 ? title || key : "",
|
||||
value: JSON.stringify(value, null, 2),
|
||||
metadata,
|
||||
renderer: textRenderer,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [agentRunOutputs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableEnhancedOutputHandling ? (
|
||||
<Card className="agpt-box" style={{ maxWidth: "950px" }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="font-poppins text-lg">Output</CardTitle>
|
||||
{outputItems.length > 0 && (
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
metadata: item.metadata,
|
||||
renderer: item.renderer,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
className="flex flex-col gap-4"
|
||||
style={{ maxWidth: "660px" }}
|
||||
>
|
||||
{agentRunOutputs !== undefined ? (
|
||||
outputItems.length > 0 ? (
|
||||
outputItems.map((item) => (
|
||||
<OutputItem
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
metadata={item.metadata}
|
||||
renderer={item.renderer}
|
||||
label={item.label}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No outputs to display
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="agpt-box" style={{ maxWidth: "950px" }}>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Output</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
className="flex flex-col gap-4"
|
||||
style={{ maxWidth: "660px" }}
|
||||
>
|
||||
{agentRunOutputs !== undefined ? (
|
||||
Object.entries(agentRunOutputs).map(
|
||||
([key, { title, values }]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{title || key}
|
||||
</label>
|
||||
{values.map((value, i) => (
|
||||
<p
|
||||
className="resize-none overflow-x-auto whitespace-pre-wrap break-words border-none text-sm text-neutral-700 disabled:cursor-not-allowed"
|
||||
key={i}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
))}
|
||||
{/* TODO: pretty type-dependent rendering */}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
|
||||
import { GraphExecutionMeta } from "@/lib/autogpt-server-api/types";
|
||||
|
||||
export type AgentRunStatus =
|
||||
| "success"
|
||||
| "failed"
|
||||
| "queued"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "scheduled"
|
||||
| "draft"
|
||||
| "review";
|
||||
|
||||
export const agentRunStatusMap: Record<
|
||||
GraphExecutionMeta["status"],
|
||||
AgentRunStatus
|
||||
> = {
|
||||
INCOMPLETE: "draft",
|
||||
COMPLETED: "success",
|
||||
FAILED: "failed",
|
||||
QUEUED: "queued",
|
||||
RUNNING: "running",
|
||||
TERMINATED: "stopped",
|
||||
REVIEW: "review",
|
||||
};
|
||||
|
||||
const statusData: Record<
|
||||
AgentRunStatus,
|
||||
{ label: string; variant: keyof typeof statusStyles }
|
||||
> = {
|
||||
success: { label: "Success", variant: "success" },
|
||||
running: { label: "Running", variant: "info" },
|
||||
failed: { label: "Failed", variant: "destructive" },
|
||||
queued: { label: "Queued", variant: "warning" },
|
||||
draft: { label: "Draft", variant: "secondary" },
|
||||
stopped: { label: "Stopped", variant: "secondary" },
|
||||
scheduled: { label: "Scheduled", variant: "secondary" },
|
||||
review: { label: "In Review", variant: "warning" },
|
||||
};
|
||||
|
||||
const statusStyles = {
|
||||
success:
|
||||
"bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800",
|
||||
destructive: "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800",
|
||||
warning:
|
||||
"bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800",
|
||||
info: "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800",
|
||||
secondary:
|
||||
"bg-slate-100 text-slate-800 hover:bg-slate-100 hover:text-slate-800",
|
||||
};
|
||||
|
||||
export function AgentRunStatusChip({
|
||||
status,
|
||||
}: {
|
||||
status: AgentRunStatus;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-xs font-medium ${statusStyles[statusData[status]?.variant]} rounded-[45px] px-[9px] py-[3px]`}
|
||||
>
|
||||
{statusData[status]?.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import React from "react";
|
||||
import { formatDistanceToNow, isPast } from "date-fns";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Link2Icon, Link2OffIcon, MoreVertical } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/__legacy__/ui/card";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/__legacy__/ui/dropdown-menu";
|
||||
|
||||
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
|
||||
import { AgentRunStatus, AgentRunStatusChip } from "./agent-run-status-chip";
|
||||
import { PushPinSimpleIcon } from "@phosphor-icons/react";
|
||||
|
||||
export type AgentRunSummaryProps = (
|
||||
| {
|
||||
type: "run";
|
||||
status: AgentRunStatus;
|
||||
}
|
||||
| {
|
||||
type: "preset";
|
||||
status?: undefined;
|
||||
}
|
||||
| {
|
||||
type: "preset.triggered";
|
||||
status: AgentStatus;
|
||||
}
|
||||
| {
|
||||
type: "schedule";
|
||||
status: "scheduled";
|
||||
}
|
||||
) & {
|
||||
title: string;
|
||||
timestamp?: number | Date;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
// onRename: () => void;
|
||||
onDelete: () => void;
|
||||
onPinAsPreset?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AgentRunSummaryCard({
|
||||
type,
|
||||
status,
|
||||
title,
|
||||
timestamp,
|
||||
selected = false,
|
||||
onClick,
|
||||
// onRename,
|
||||
onDelete,
|
||||
onPinAsPreset,
|
||||
className,
|
||||
}: AgentRunSummaryProps): React.ReactElement {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"agpt-rounded-card cursor-pointer border-zinc-300",
|
||||
selected ? "agpt-card-selected" : "",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="relative p-2.5 lg:p-4">
|
||||
{(type == "run" || type == "schedule") && (
|
||||
<AgentRunStatusChip status={status} />
|
||||
)}
|
||||
{type == "preset" && (
|
||||
<div className="flex items-center text-sm font-medium text-neutral-700">
|
||||
<PushPinSimpleIcon className="mr-1 size-4 text-foreground" /> Preset
|
||||
</div>
|
||||
)}
|
||||
{type == "preset.triggered" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<AgentStatusChip status={status} />
|
||||
|
||||
<div className="flex items-center text-sm font-medium text-neutral-700">
|
||||
{status == "inactive" ? (
|
||||
<Link2OffIcon className="mr-1 size-4 text-foreground" />
|
||||
) : (
|
||||
<Link2Icon className="mr-1 size-4 text-foreground" />
|
||||
)}{" "}
|
||||
Trigger
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<h3 className="truncate pr-2 text-base font-medium text-neutral-900">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-5 w-5 p-0">
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{onPinAsPreset && (
|
||||
<DropdownMenuItem onClick={onPinAsPreset}>
|
||||
Pin as a preset
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* <DropdownMenuItem onClick={onRename}>Rename</DropdownMenuItem> */}
|
||||
|
||||
<DropdownMenuItem onClick={onDelete}>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{timestamp && (
|
||||
<p
|
||||
className="mt-1 text-sm font-normal text-neutral-500"
|
||||
title={new Date(timestamp).toString()}
|
||||
>
|
||||
{isPast(timestamp) ? "Ran" : "Runs in"}{" "}
|
||||
{formatDistanceToNow(timestamp, { addSuffix: true })}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
"use client";
|
||||
import { Plus } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
GraphExecutionID,
|
||||
GraphExecutionMeta,
|
||||
LibraryAgent,
|
||||
LibraryAgentPreset,
|
||||
LibraryAgentPresetID,
|
||||
Schedule,
|
||||
ScheduleID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import LoadingBox, { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { AgentRunsQuery } from "../use-agent-runs";
|
||||
import { agentRunStatusMap } from "./agent-run-status-chip";
|
||||
import { AgentRunSummaryCard } from "./agent-run-summary-card";
|
||||
|
||||
interface AgentRunsSelectorListProps {
|
||||
agent: LibraryAgent;
|
||||
agentRunsQuery: AgentRunsQuery;
|
||||
agentPresets: LibraryAgentPreset[];
|
||||
schedules: Schedule[];
|
||||
selectedView: { type: "run" | "preset" | "schedule"; id?: string };
|
||||
allowDraftNewRun?: boolean;
|
||||
onSelectRun: (id: GraphExecutionID) => void;
|
||||
onSelectPreset: (preset: LibraryAgentPresetID) => void;
|
||||
onSelectSchedule: (id: ScheduleID) => void;
|
||||
onSelectDraftNewRun: () => void;
|
||||
doDeleteRun: (id: GraphExecutionMeta) => void;
|
||||
doDeletePreset: (id: LibraryAgentPresetID) => void;
|
||||
doDeleteSchedule: (id: ScheduleID) => void;
|
||||
doCreatePresetFromRun?: (id: GraphExecutionID) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentRunsSelectorList({
|
||||
agent,
|
||||
agentRunsQuery: {
|
||||
agentRuns,
|
||||
agentRunCount,
|
||||
agentRunsLoading,
|
||||
hasMoreRuns,
|
||||
fetchMoreRuns,
|
||||
isFetchingMoreRuns,
|
||||
},
|
||||
agentPresets,
|
||||
schedules,
|
||||
selectedView,
|
||||
allowDraftNewRun = true,
|
||||
onSelectRun,
|
||||
onSelectPreset,
|
||||
onSelectSchedule,
|
||||
onSelectDraftNewRun,
|
||||
doDeleteRun,
|
||||
doDeletePreset,
|
||||
doDeleteSchedule,
|
||||
doCreatePresetFromRun,
|
||||
className,
|
||||
}: AgentRunsSelectorListProps): React.ReactElement {
|
||||
const [activeListTab, setActiveListTab] = useState<"runs" | "scheduled">(
|
||||
"runs",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedView.type === "schedule") {
|
||||
setActiveListTab("scheduled");
|
||||
} else {
|
||||
setActiveListTab("runs");
|
||||
}
|
||||
}, [selectedView]);
|
||||
|
||||
const listItemClasses = "h-28 w-72 lg:w-full lg:h-32";
|
||||
|
||||
return (
|
||||
<aside className={cn("flex flex-col gap-4", className)}>
|
||||
{allowDraftNewRun ? (
|
||||
<Button
|
||||
className={"mb-4 hidden lg:flex"}
|
||||
onClick={onSelectDraftNewRun}
|
||||
leftIcon={<Plus className="h-6 w-6" />}
|
||||
>
|
||||
New {agent.has_external_trigger ? "trigger" : "run"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge
|
||||
variant={activeListTab === "runs" ? "secondary" : "outline"}
|
||||
className="cursor-pointer gap-2 rounded-full text-base"
|
||||
onClick={() => setActiveListTab("runs")}
|
||||
>
|
||||
<span>Runs</span>
|
||||
<span className="text-neutral-600">
|
||||
{agentRunCount ?? <LoadingSpinner className="size-4" />}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant={activeListTab === "scheduled" ? "secondary" : "outline"}
|
||||
className="cursor-pointer gap-2 rounded-full text-base"
|
||||
onClick={() => setActiveListTab("scheduled")}
|
||||
>
|
||||
<span>Scheduled</span>
|
||||
<span className="text-neutral-600">{schedules.length}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Runs / Schedules list */}
|
||||
{agentRunsLoading && activeListTab === "runs" ? (
|
||||
<LoadingBox className="h-28 w-full lg:h-[calc(100vh-300px)] lg:w-72 xl:w-80" />
|
||||
) : (
|
||||
<ScrollArea
|
||||
className="w-full lg:h-[calc(100vh-300px)] lg:w-72 xl:w-80"
|
||||
orientation={window.innerWidth >= 1024 ? "vertical" : "horizontal"}
|
||||
>
|
||||
<InfiniteScroll
|
||||
direction={window.innerWidth >= 1024 ? "vertical" : "horizontal"}
|
||||
hasNextPage={hasMoreRuns}
|
||||
fetchNextPage={fetchMoreRuns}
|
||||
isFetchingNextPage={isFetchingMoreRuns}
|
||||
>
|
||||
<div className="flex items-center gap-2 lg:flex-col">
|
||||
{/* New Run button - only in small layouts */}
|
||||
{allowDraftNewRun && (
|
||||
<Button
|
||||
size="large"
|
||||
className={
|
||||
"flex h-12 w-40 items-center gap-2 py-6 lg:hidden " +
|
||||
(selectedView.type == "run" && !selectedView.id
|
||||
? "agpt-card-selected text-accent"
|
||||
: "")
|
||||
}
|
||||
onClick={onSelectDraftNewRun}
|
||||
leftIcon={<Plus className="h-6 w-6" />}
|
||||
>
|
||||
New {agent.has_external_trigger ? "trigger" : "run"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{activeListTab === "runs" ? (
|
||||
<>
|
||||
{agentPresets
|
||||
.filter((preset) => preset.webhook) // Triggers
|
||||
.toSorted(
|
||||
(a, b) => b.updated_at.getTime() - a.updated_at.getTime(),
|
||||
)
|
||||
.map((preset) => (
|
||||
<AgentRunSummaryCard
|
||||
className={cn(listItemClasses, "lg:h-auto")}
|
||||
key={preset.id}
|
||||
type="preset.triggered"
|
||||
status={preset.is_active ? "active" : "inactive"}
|
||||
title={preset.name}
|
||||
// timestamp={preset.last_run_time} // TODO: implement this
|
||||
selected={selectedView.id === preset.id}
|
||||
onClick={() => onSelectPreset(preset.id)}
|
||||
onDelete={() => doDeletePreset(preset.id)}
|
||||
/>
|
||||
))}
|
||||
{agentPresets
|
||||
.filter((preset) => !preset.webhook) // Presets
|
||||
.toSorted(
|
||||
(a, b) => b.updated_at.getTime() - a.updated_at.getTime(),
|
||||
)
|
||||
.map((preset) => (
|
||||
<AgentRunSummaryCard
|
||||
className={cn(listItemClasses, "lg:h-auto")}
|
||||
key={preset.id}
|
||||
type="preset"
|
||||
title={preset.name}
|
||||
// timestamp={preset.last_run_time} // TODO: implement this
|
||||
selected={selectedView.id === preset.id}
|
||||
onClick={() => onSelectPreset(preset.id)}
|
||||
onDelete={() => doDeletePreset(preset.id)}
|
||||
/>
|
||||
))}
|
||||
{agentPresets.length > 0 && <Separator className="my-1" />}
|
||||
{agentRuns
|
||||
.toSorted((a, b) => {
|
||||
const aTime = a.started_at?.getTime() ?? 0;
|
||||
const bTime = b.started_at?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.map((run) => (
|
||||
<AgentRunSummaryCard
|
||||
className={listItemClasses}
|
||||
key={run.id}
|
||||
type="run"
|
||||
status={agentRunStatusMap[run.status]}
|
||||
title={
|
||||
(run.preset_id
|
||||
? agentPresets.find((p) => p.id == run.preset_id)
|
||||
?.name
|
||||
: null) ?? agent.name
|
||||
}
|
||||
timestamp={run.started_at ?? undefined}
|
||||
selected={selectedView.id === run.id}
|
||||
onClick={() => onSelectRun(run.id)}
|
||||
onDelete={() => doDeleteRun(run as GraphExecutionMeta)}
|
||||
onPinAsPreset={
|
||||
doCreatePresetFromRun
|
||||
? () => doCreatePresetFromRun(run.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
schedules.map((schedule) => (
|
||||
<AgentRunSummaryCard
|
||||
className={listItemClasses}
|
||||
key={schedule.id}
|
||||
type="schedule"
|
||||
status="scheduled" // TODO: implement active/inactive status for schedules
|
||||
title={schedule.name}
|
||||
timestamp={schedule.next_run_time}
|
||||
selected={selectedView.id === schedule.id}
|
||||
onClick={() => onSelectSchedule(schedule.id)}
|
||||
onDelete={() => doDeleteSchedule(schedule.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
"use client";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
GraphExecutionID,
|
||||
Schedule,
|
||||
ScheduleID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
import ActionButtonGroup from "@/components/__legacy__/action-button-group";
|
||||
import type { ButtonAction } from "@/components/__legacy__/types";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import { IconCross } from "@/components/__legacy__/ui/icons";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { formatScheduleTime } from "@/lib/timezone-utils";
|
||||
import { useUserTimezone } from "@/lib/hooks/useUserTimezone";
|
||||
import { PlayIcon } from "lucide-react";
|
||||
|
||||
import { AgentRunStatus } from "./agent-run-status-chip";
|
||||
|
||||
export function AgentScheduleDetailsView({
|
||||
graph,
|
||||
schedule,
|
||||
agentActions,
|
||||
onForcedRun,
|
||||
doDeleteSchedule,
|
||||
}: {
|
||||
graph: Graph;
|
||||
schedule: Schedule;
|
||||
agentActions: ButtonAction[];
|
||||
onForcedRun: (runID: GraphExecutionID) => void;
|
||||
doDeleteSchedule: (scheduleID: ScheduleID) => void;
|
||||
}): React.ReactNode {
|
||||
const api = useBackendAPI();
|
||||
|
||||
const selectedRunStatus: AgentRunStatus = "scheduled";
|
||||
|
||||
const toastOnFail = useToastOnFail();
|
||||
|
||||
// Get user's timezone for displaying schedule times
|
||||
const userTimezone = useUserTimezone();
|
||||
|
||||
const infoStats: { label: string; value: React.ReactNode }[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: "Status",
|
||||
value:
|
||||
selectedRunStatus.charAt(0).toUpperCase() +
|
||||
selectedRunStatus.slice(1),
|
||||
},
|
||||
{
|
||||
label: "Schedule",
|
||||
value: humanizeCronExpression(schedule.cron),
|
||||
},
|
||||
{
|
||||
label: "Next run",
|
||||
value: formatScheduleTime(schedule.next_run_time, userTimezone),
|
||||
},
|
||||
];
|
||||
}, [schedule, selectedRunStatus, userTimezone]);
|
||||
|
||||
const agentRunInputs: Record<
|
||||
string,
|
||||
{ title?: string; /* type: BlockIOSubType; */ value: any }
|
||||
> = useMemo(() => {
|
||||
// TODO: show (link to) preset - https://github.com/Significant-Gravitas/AutoGPT/issues/9168
|
||||
|
||||
// Add type info from agent input schema
|
||||
return Object.fromEntries(
|
||||
Object.entries(schedule.input_data).map(([k, v]) => [
|
||||
k,
|
||||
{
|
||||
title: graph.input_schema.properties[k].title,
|
||||
/* TODO: type: agent.input_schema.properties[k].type */
|
||||
value: v,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}, [graph, schedule]);
|
||||
|
||||
const runNow = useCallback(
|
||||
() =>
|
||||
api
|
||||
.executeGraph(
|
||||
graph.id,
|
||||
graph.version,
|
||||
schedule.input_data,
|
||||
schedule.input_credentials,
|
||||
"library",
|
||||
)
|
||||
.then((run) => onForcedRun(run.id))
|
||||
.catch(toastOnFail("execute agent")),
|
||||
[api, graph, schedule, onForcedRun, toastOnFail],
|
||||
);
|
||||
|
||||
const runActions: ButtonAction[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<PlayIcon className="mr-2 size-4" />
|
||||
Run now
|
||||
</>
|
||||
),
|
||||
callback: runNow,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<IconCross className="mr-2 size-4 px-0.5" />
|
||||
Delete schedule
|
||||
</>
|
||||
),
|
||||
callback: () => doDeleteSchedule(schedule.id),
|
||||
variant: "destructive",
|
||||
},
|
||||
],
|
||||
[runNow],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="agpt-div flex gap-6">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Info</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex justify-stretch gap-4">
|
||||
{infoStats.map(({ label, value }) => (
|
||||
<div key={label} className="flex-1">
|
||||
<p className="text-sm font-medium text-black">{label}</p>
|
||||
<p className="text-sm text-neutral-600">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Input</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{agentRunInputs !== undefined ? (
|
||||
Object.entries(agentRunInputs).map(([key, { title, value }]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{title || key}</label>
|
||||
<Input value={value} className="rounded-full" disabled />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Run / Agent Actions */}
|
||||
<aside className="w-48 xl:w-56">
|
||||
<div className="flex flex-col gap-8">
|
||||
<ActionButtonGroup title="Run actions" actions={runActions} />
|
||||
|
||||
<ActionButtonGroup title="Agent actions" actions={agentActions} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Textarea } from "@/components/__legacy__/ui/textarea";
|
||||
|
||||
interface CreatePresetDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (name: string, description: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function CreatePresetDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: CreatePresetDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (name.trim()) {
|
||||
await onConfirm(name.trim(), description.trim());
|
||||
setName("");
|
||||
setDescription("");
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Preset</DialogTitle>
|
||||
<DialogDescription>
|
||||
Give your preset a name and description to help identify it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="preset-name" className="text-sm font-medium">
|
||||
Name *
|
||||
</label>
|
||||
<Input
|
||||
id="preset-name"
|
||||
placeholder="Enter preset name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="preset-description" className="text-sm font-medium">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="preset-description"
|
||||
placeholder="Optional description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Create Preset
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import {
|
||||
GraphExecutionMeta as LegacyGraphExecutionMeta,
|
||||
GraphID,
|
||||
GraphExecutionID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import {
|
||||
getPaginatedTotalCount,
|
||||
getPaginationNextPageNumber,
|
||||
unpaginate,
|
||||
} from "@/app/api/helpers";
|
||||
import {
|
||||
getV1ListGraphExecutionsResponse,
|
||||
getV1ListGraphExecutionsResponse200,
|
||||
useGetV1ListGraphExecutionsInfinite,
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { GraphExecutionsPaginated } from "@/app/api/__generated__/models/graphExecutionsPaginated";
|
||||
import { GraphExecutionMeta as RawGraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
|
||||
export type GraphExecutionMeta = Omit<
|
||||
RawGraphExecutionMeta,
|
||||
"id" | "user_id" | "graph_id" | "preset_id" | "stats"
|
||||
> &
|
||||
Pick<
|
||||
LegacyGraphExecutionMeta,
|
||||
"id" | "user_id" | "graph_id" | "preset_id" | "stats"
|
||||
>;
|
||||
|
||||
/** Hook to fetch runs for a specific graph, with support for infinite scroll.
|
||||
*
|
||||
* @param graphID - The ID of the graph to fetch agent runs for. This parameter is
|
||||
* optional in the sense that the hook doesn't run unless it is passed.
|
||||
* This way, it can be used in components where the graph ID is not
|
||||
* immediately available.
|
||||
*/
|
||||
export const useAgentRunsInfinite = (graphID?: GraphID) => {
|
||||
const queryClient = getQueryClient();
|
||||
const {
|
||||
data: queryResults,
|
||||
refetch: refetchRuns,
|
||||
isPending: agentRunsLoading,
|
||||
isRefetching: agentRunsReloading,
|
||||
hasNextPage: hasMoreRuns,
|
||||
fetchNextPage: fetchMoreRuns,
|
||||
isFetchingNextPage: isFetchingMoreRuns,
|
||||
queryKey,
|
||||
} = useGetV1ListGraphExecutionsInfinite(
|
||||
graphID!,
|
||||
{ page: 1, page_size: 20 },
|
||||
{
|
||||
query: {
|
||||
getNextPageParam: getPaginationNextPageNumber,
|
||||
|
||||
// Prevent query from running if graphID is not available (yet)
|
||||
...(!graphID
|
||||
? {
|
||||
enabled: false,
|
||||
queryFn: () =>
|
||||
// Fake empty response if graphID is not available (yet)
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
data: {
|
||||
executions: [],
|
||||
pagination: {
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
},
|
||||
},
|
||||
headers: new Headers(),
|
||||
} satisfies getV1ListGraphExecutionsResponse),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const agentRuns = queryResults ? unpaginate(queryResults, "executions") : [];
|
||||
const agentRunCount = getPaginatedTotalCount(queryResults);
|
||||
|
||||
const upsertAgentRun = (newAgentRun: GraphExecutionMeta) => {
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
(currentQueryData: typeof queryResults) => {
|
||||
if (!currentQueryData?.pages || agentRunCount === undefined)
|
||||
return currentQueryData;
|
||||
|
||||
const exists = currentQueryData.pages.some((page) => {
|
||||
if (page.status !== 200) return false;
|
||||
|
||||
const response = page.data;
|
||||
return response.executions.some((run) => run.id === newAgentRun.id);
|
||||
});
|
||||
if (exists) {
|
||||
// If the run already exists, we update it
|
||||
return {
|
||||
...currentQueryData,
|
||||
pages: currentQueryData.pages.map((page) => {
|
||||
if (page.status !== 200) return page;
|
||||
const response = page.data;
|
||||
const executions = response.executions;
|
||||
|
||||
const index = executions.findIndex(
|
||||
(run) => run.id === newAgentRun.id,
|
||||
);
|
||||
if (index === -1) return page;
|
||||
|
||||
const newExecutions = [...executions];
|
||||
newExecutions[index] = newAgentRun;
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: {
|
||||
...response,
|
||||
executions: newExecutions,
|
||||
},
|
||||
} satisfies getV1ListGraphExecutionsResponse;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// If the run does not exist, we add it to the first page
|
||||
const page = currentQueryData
|
||||
.pages[0] as getV1ListGraphExecutionsResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
const updatedExecutions = [newAgentRun, ...page.data.executions];
|
||||
const updatedPage = {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
executions: updatedExecutions,
|
||||
},
|
||||
} satisfies getV1ListGraphExecutionsResponse;
|
||||
const updatedPages = [updatedPage, ...currentQueryData.pages.slice(1)];
|
||||
return {
|
||||
...currentQueryData,
|
||||
pages: updatedPages.map(
|
||||
// Increment the total runs count in the pagination info of all pages
|
||||
(page) =>
|
||||
page.status === 200
|
||||
? {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
pagination: {
|
||||
...page.data.pagination,
|
||||
total_items: agentRunCount + 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
: page,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeAgentRun = (runID: GraphExecutionID) => {
|
||||
queryClient.setQueryData(
|
||||
[queryKey, { page: 1, page_size: 20 }],
|
||||
(currentQueryData: typeof queryResults) => {
|
||||
if (!currentQueryData?.pages) return currentQueryData;
|
||||
|
||||
let found = false;
|
||||
return {
|
||||
...currentQueryData,
|
||||
pages: currentQueryData.pages.map((page) => {
|
||||
const response = page.data as GraphExecutionsPaginated;
|
||||
const filteredExecutions = response.executions.filter(
|
||||
(run) => run.id !== runID,
|
||||
);
|
||||
if (filteredExecutions.length < response.executions.length) {
|
||||
found = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: {
|
||||
...response,
|
||||
executions: filteredExecutions,
|
||||
pagination: {
|
||||
...response.pagination,
|
||||
total_items:
|
||||
response.pagination.total_items - (found ? 1 : 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
agentRuns: agentRuns as GraphExecutionMeta[],
|
||||
refetchRuns,
|
||||
agentRunCount,
|
||||
agentRunsLoading: agentRunsLoading || agentRunsReloading,
|
||||
hasMoreRuns,
|
||||
fetchMoreRuns,
|
||||
isFetchingMoreRuns,
|
||||
upsertAgentRun,
|
||||
removeAgentRun,
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentRunsQuery = ReturnType<typeof useAgentRunsInfinite>;
|
||||
@@ -1,31 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { HeartIcon } from "@phosphor-icons/react";
|
||||
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { LibraryTabs, Tab } from "../LibraryTabs/LibraryTabs";
|
||||
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
}
|
||||
|
||||
export function FavoritesSection({
|
||||
searchTerm,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
setLibrarySort,
|
||||
}: Props) {
|
||||
export function FavoritesSection({ searchTerm }: Props) {
|
||||
const {
|
||||
allAgents: favoriteAgents,
|
||||
agentLoading: isLoading,
|
||||
@@ -35,33 +21,38 @@ export function FavoritesSection({
|
||||
isFetchingNextPage,
|
||||
} = useFavoriteAgents({ searchTerm });
|
||||
|
||||
return (
|
||||
<>
|
||||
<LibraryActionSubHeader
|
||||
agentCount={agentCount}
|
||||
setLibrarySort={setLibrarySort}
|
||||
/>
|
||||
<LibraryTabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
if (isLoading || favoriteAgents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-[200px] items-center justify-center">
|
||||
<LoadingSpinner size="large" />
|
||||
return (
|
||||
<div className="!mb-8">
|
||||
<div className="mb-3 flex items-center gap-2 p-2">
|
||||
<HeartIcon className="h-5 w-5" weight="fill" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Text variant="h4">Favorites</Text>
|
||||
{!isLoading && (
|
||||
<Text
|
||||
variant="body"
|
||||
data-testid="agents-count"
|
||||
className="relative bottom-px text-zinc-500"
|
||||
>
|
||||
{agentCount}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
) : favoriteAgents.length === 0 ? (
|
||||
<div className="flex h-[200px] flex-col items-center justify-center gap-2 text-zinc-500">
|
||||
<HeartIcon className="h-10 w-10" />
|
||||
<Text variant="body">No favorite agents yet</Text>
|
||||
</div>
|
||||
) : (
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<InfiniteScroll
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loader={<LoadingSpinner size="medium" />}
|
||||
loader={
|
||||
<div className="flex h-8 w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{favoriteAgents.map((agent: LibraryAgent) => (
|
||||
@@ -69,7 +60,9 @@ export function FavoritesSection({
|
||||
))}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
{favoriteAgents.length > 0 && <div className="!mt-10 border-t" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { HeartIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface FlyingHeartProps {
|
||||
startPosition: { x: number; y: number } | null;
|
||||
targetPosition: { x: number; y: number } | null;
|
||||
onAnimationComplete: () => void;
|
||||
}
|
||||
|
||||
export function FlyingHeart({
|
||||
startPosition,
|
||||
targetPosition,
|
||||
onAnimationComplete,
|
||||
}: FlyingHeartProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (startPosition && targetPosition) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, [startPosition, targetPosition]);
|
||||
|
||||
if (!startPosition || !targetPosition) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
className="pointer-events-none fixed z-50"
|
||||
initial={{
|
||||
x: startPosition.x,
|
||||
y: startPosition.y,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
animate={{
|
||||
x: targetPosition.x,
|
||||
y: targetPosition.y,
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
}}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
stiffness: 200,
|
||||
duration: 0.5,
|
||||
}}
|
||||
onAnimationComplete={() => {
|
||||
setIsVisible(false);
|
||||
onAnimationComplete();
|
||||
}}
|
||||
>
|
||||
<HeartIcon
|
||||
size={24}
|
||||
weight="fill"
|
||||
className="text-red-500 drop-shadow-md"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function LibraryActionSubHeader({ agentCount, setLibrarySort }: Props) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<Text variant="h5">My agents</Text>
|
||||
<Text variant="h4">My agents</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
data-testid="agents-count"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CaretCircleRightIcon } from "@phosphor-icons/react";
|
||||
import Image from "next/image";
|
||||
import NextLink from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import Avatar, {
|
||||
@@ -15,21 +14,13 @@ import { Link } from "@/components/atoms/Link/Link";
|
||||
import { AgentCardMenu } from "./components/AgentCardMenu";
|
||||
import { FavoriteButton } from "./components/FavoriteButton";
|
||||
import { useLibraryAgentCard } from "./useLibraryAgentCard";
|
||||
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
export function LibraryAgentCard({ agent }: Props) {
|
||||
const { id, name, graph_id, can_access_graph, image_url } = agent;
|
||||
const { triggerFavoriteAnimation } = useFavoriteAnimation();
|
||||
|
||||
function handleDragStart(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.dataTransfer.setData("application/agent-id", id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
const {
|
||||
isFromMarketplace,
|
||||
@@ -37,119 +28,103 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
profile,
|
||||
creator_image_url,
|
||||
handleToggleFavorite,
|
||||
} = useLibraryAgentCard({
|
||||
agent,
|
||||
onFavoriteAdd: triggerFavoriteAnimation,
|
||||
});
|
||||
} = useLibraryAgentCard({ agent });
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={handleDragStart}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
data-testid="library-agent-card"
|
||||
data-agent-id={id}
|
||||
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white transition-all duration-300 hover:shadow-md"
|
||||
>
|
||||
<motion.div
|
||||
layoutId={`agent-card-${id}`}
|
||||
data-testid="library-agent-card"
|
||||
data-agent-id={id}
|
||||
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white hover:shadow-md"
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 25,
|
||||
stiffness: 300,
|
||||
}}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2 px-4 pt-3">
|
||||
<Avatar className="h-4 w-4 rounded-full">
|
||||
<AvatarImage
|
||||
src={
|
||||
isFromMarketplace
|
||||
? creator_image_url || "/avatar-placeholder.png"
|
||||
: profile?.avatar_url || "/avatar-placeholder.png"
|
||||
}
|
||||
alt={`${name} creator avatar`}
|
||||
/>
|
||||
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className="uppercase tracking-wide text-zinc-400"
|
||||
>
|
||||
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
|
||||
</Text>
|
||||
</div>
|
||||
</NextLink>
|
||||
<FavoriteButton
|
||||
isFavorite={isFavorite}
|
||||
onClick={handleToggleFavorite}
|
||||
className="absolute right-10 top-0"
|
||||
/>
|
||||
<AgentCardMenu agent={agent} />
|
||||
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2 px-4 pt-3">
|
||||
<Avatar className="h-4 w-4 rounded-full">
|
||||
<AvatarImage
|
||||
src={
|
||||
isFromMarketplace
|
||||
? creator_image_url || "/avatar-placeholder.png"
|
||||
: profile?.avatar_url || "/avatar-placeholder.png"
|
||||
}
|
||||
alt={`${name} creator avatar`}
|
||||
/>
|
||||
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className="uppercase tracking-wide text-zinc-400"
|
||||
>
|
||||
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
|
||||
</Text>
|
||||
</div>
|
||||
</NextLink>
|
||||
<FavoriteButton
|
||||
isFavorite={isFavorite}
|
||||
onClick={handleToggleFavorite}
|
||||
className="absolute right-10 top-0"
|
||||
/>
|
||||
<AgentCardMenu agent={agent} />
|
||||
|
||||
<div className="flex w-full flex-1 flex-col px-4 pb-2">
|
||||
<div className="flex w-full flex-1 flex-col px-4 pb-2">
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
className="flex w-full items-start justify-between gap-2 no-underline hover:no-underline"
|
||||
>
|
||||
<Text
|
||||
variant="h5"
|
||||
data-testid="library-agent-card-name"
|
||||
className="line-clamp-3 hyphens-auto break-words no-underline hover:no-underline"
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
|
||||
{!image_url ? (
|
||||
<div
|
||||
className={`h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small ${
|
||||
[
|
||||
"bg-gradient-to-r from-green-200 to-blue-200",
|
||||
"bg-gradient-to-r from-pink-200 to-purple-200",
|
||||
"bg-gradient-to-r from-yellow-200 to-orange-200",
|
||||
"bg-gradient-to-r from-blue-200 to-cyan-200",
|
||||
"bg-gradient-to-r from-indigo-200 to-purple-200",
|
||||
][parseInt(id.slice(0, 8), 16) % 5]
|
||||
}`}
|
||||
style={{
|
||||
backgroundSize: "200% 200%",
|
||||
animation: "gradient 15s ease infinite",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={image_url}
|
||||
alt={`${name} preview image`}
|
||||
width={107}
|
||||
height={58}
|
||||
className="flex-shrink-0 rounded-small object-cover"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
className="flex w-full items-start justify-between gap-2 no-underline hover:no-underline"
|
||||
data-testid="library-agent-card-see-runs-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
>
|
||||
<Text
|
||||
variant="h5"
|
||||
data-testid="library-agent-card-name"
|
||||
className="line-clamp-3 hyphens-auto break-words no-underline hover:no-underline"
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
|
||||
{!image_url ? (
|
||||
<div
|
||||
className={`h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small ${
|
||||
[
|
||||
"bg-gradient-to-r from-green-200 to-blue-200",
|
||||
"bg-gradient-to-r from-pink-200 to-purple-200",
|
||||
"bg-gradient-to-r from-yellow-200 to-orange-200",
|
||||
"bg-gradient-to-r from-blue-200 to-cyan-200",
|
||||
"bg-gradient-to-r from-indigo-200 to-purple-200",
|
||||
][parseInt(id.slice(0, 8), 16) % 5]
|
||||
}`}
|
||||
style={{
|
||||
backgroundSize: "200% 200%",
|
||||
animation: "gradient 15s ease infinite",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={image_url}
|
||||
alt={`${name} preview image`}
|
||||
width={107}
|
||||
height={58}
|
||||
className="flex-shrink-0 rounded-small object-cover"
|
||||
/>
|
||||
)}
|
||||
See runs <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
|
||||
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
|
||||
{can_access_graph && (
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
data-testid="library-agent-card-see-runs-link"
|
||||
href={`/build?flowID=${graph_id}`}
|
||||
data-testid="library-agent-card-open-in-builder-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
isExternal
|
||||
>
|
||||
See runs <CaretCircleRightIcon size={20} />
|
||||
Open in builder <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
|
||||
{can_access_graph && (
|
||||
<Link
|
||||
href={`/build?flowID=${graph_id}`}
|
||||
data-testid="library-agent-card-open-in-builder-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
isExternal
|
||||
>
|
||||
Open in builder <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@ import {
|
||||
useDeleteV2DeleteLibraryAgent,
|
||||
usePostV2ForkLibraryAgent,
|
||||
} from "@/app/api/__generated__/endpoints/library/library";
|
||||
import {
|
||||
usePostV2BulkMoveAgents,
|
||||
getGetV2ListLibraryFoldersQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/folders/folders";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
@@ -26,7 +22,6 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { MoveToFolderDialog } from "../../MoveToFolderDialog/MoveToFolderDialog";
|
||||
|
||||
interface AgentCardMenuProps {
|
||||
agent: LibraryAgent;
|
||||
@@ -37,25 +32,11 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showMoveDialog, setShowMoveDialog] = useState(false);
|
||||
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
|
||||
const [isDuplicatingAgent, setIsDuplicatingAgent] = useState(false);
|
||||
const [isRemovingFromFolder, setIsRemovingFromFolder] = useState(false);
|
||||
|
||||
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
|
||||
const { mutateAsync: forkAgent } = usePostV2ForkLibraryAgent();
|
||||
const { mutateAsync: bulkMoveAgents } = usePostV2BulkMoveAgents({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function handleDuplicateAgent() {
|
||||
if (!agent.id) return;
|
||||
@@ -89,37 +70,6 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveFromFolder() {
|
||||
if (!agent.id) return;
|
||||
|
||||
setIsRemovingFromFolder(true);
|
||||
|
||||
try {
|
||||
await bulkMoveAgents({
|
||||
data: {
|
||||
agent_ids: [agent.id],
|
||||
folder_id: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Removed from folder",
|
||||
description: "Agent has been moved back to your library.",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "Failed to remove from folder",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsRemovingFromFolder(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAgent() {
|
||||
if (!agent.id) return;
|
||||
|
||||
@@ -188,31 +138,6 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
|
||||
Duplicate agent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMoveDialog(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Move to folder
|
||||
</DropdownMenuItem>
|
||||
{agent.folder_id && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFolder();
|
||||
}}
|
||||
disabled={isRemovingFromFolder}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Remove from folder
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -258,14 +183,6 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
|
||||
<MoveToFolderDialog
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
currentFolderId={agent.folder_id}
|
||||
isOpen={showMoveDialog}
|
||||
setIsOpen={setShowMoveDialog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeartIcon } from "@phosphor-icons/react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
isFavorite: boolean;
|
||||
onClick: (
|
||||
e: MouseEvent<HTMLButtonElement>,
|
||||
position: { x: number; y: number },
|
||||
) => void;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -20,49 +15,25 @@ export function FavoriteButton({
|
||||
onClick,
|
||||
className,
|
||||
}: FavoriteButtonProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
function handleClick(e: MouseEvent<HTMLButtonElement>) {
|
||||
const rect = buttonRef.current?.getBoundingClientRect();
|
||||
const position = rect
|
||||
? {
|
||||
x: rect.left + rect.width / 2 - 12,
|
||||
y: rect.top + rect.height / 2 - 12,
|
||||
}
|
||||
: { x: 0, y: 0 };
|
||||
onClick(e, position);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={handleClick}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full p-2 transition-all duration-200",
|
||||
"hover:scale-110 active:scale-95",
|
||||
"hover:scale-110",
|
||||
!isFavorite && "opacity-0 group-hover:opacity-100",
|
||||
className,
|
||||
)}
|
||||
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={isFavorite ? "filled" : "empty"}
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.5, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 15, stiffness: 300 }}
|
||||
>
|
||||
<HeartIcon
|
||||
size={20}
|
||||
weight={isFavorite ? "fill" : "regular"}
|
||||
className={cn(
|
||||
"transition-colors duration-200",
|
||||
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<HeartIcon
|
||||
size={20}
|
||||
weight={isFavorite ? "fill" : "regular"}
|
||||
className={cn(
|
||||
"transition-colors duration-200",
|
||||
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ import { updateFavoriteInQueries } from "./helpers";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
onFavoriteAdd?: (position: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
export function useLibraryAgentCard({ agent, onFavoriteAdd }: Props) {
|
||||
const { id, is_favorite, creator_image_url, marketplace_listing } = agent;
|
||||
export function useLibraryAgentCard({ agent }: Props) {
|
||||
const { id, name, is_favorite, creator_image_url, marketplace_listing } =
|
||||
agent;
|
||||
|
||||
const isFromMarketplace = Boolean(marketplace_listing);
|
||||
const [isFavorite, setIsFavorite] = useState(is_favorite);
|
||||
@@ -49,31 +49,26 @@ export function useLibraryAgentCard({ agent, onFavoriteAdd }: Props) {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleToggleFavorite(
|
||||
e: React.MouseEvent,
|
||||
position: { x: number; y: number },
|
||||
) {
|
||||
async function handleToggleFavorite(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const newIsFavorite = !isFavorite;
|
||||
|
||||
// Optimistic update - update UI immediately
|
||||
setIsFavorite(newIsFavorite);
|
||||
updateQueryData(newIsFavorite);
|
||||
|
||||
// Trigger animation immediately for adding to favorites
|
||||
if (newIsFavorite && onFavoriteAdd) {
|
||||
onFavoriteAdd(position);
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLibraryAgent({
|
||||
libraryAgentId: id,
|
||||
data: { is_favorite: newIsFavorite },
|
||||
});
|
||||
|
||||
toast({
|
||||
title: newIsFavorite ? "Added to favorites" : "Removed from favorites",
|
||||
description: `${name} has been ${newIsFavorite ? "added to" : "removed from"} your favorites.`,
|
||||
});
|
||||
} catch {
|
||||
// Revert on failure
|
||||
setIsFavorite(!newIsFavorite);
|
||||
updateQueryData(!newIsFavorite);
|
||||
|
||||
|
||||
@@ -1,130 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { LibraryFolder } from "../LibraryFolder/LibraryFolder";
|
||||
import { LibrarySubSection } from "../LibrarySubSection/LibrarySubSection";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ArrowLeftIcon, HeartIcon } from "@phosphor-icons/react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Tab } from "../LibraryTabs/LibraryTabs";
|
||||
import {
|
||||
AnimatePresence,
|
||||
LayoutGroup,
|
||||
motion,
|
||||
useReducedMotion,
|
||||
} from "framer-motion";
|
||||
import { LibraryFolderEditDialog } from "../LibraryFolderEditDialog/LibraryFolderEditDialog";
|
||||
import { LibraryFolderDeleteDialog } from "../LibraryFolderDeleteDialog/LibraryFolderDeleteDialog";
|
||||
import { useLibraryAgentList } from "./useLibraryAgentList";
|
||||
|
||||
// Spring-based enter/exit animations (Emil Kowalski principles)
|
||||
// Springs are naturally interruptible — switching tabs mid-animation
|
||||
// cancels the current spring and starts a new one from current state.
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
show: {},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
transition: { duration: 0.12 },
|
||||
},
|
||||
};
|
||||
|
||||
// Reduced motion fallback
|
||||
const reducedContainerVariants = {
|
||||
hidden: {},
|
||||
show: {},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { duration: 0.12 },
|
||||
},
|
||||
};
|
||||
|
||||
// Per-item animation values (explicit initial/animate, not variant-based).
|
||||
// This ensures items animate in on mount regardless of parent state — fixes
|
||||
// the bug where dynamically added children (e.g. folders reappearing after
|
||||
// search is cleared) stayed invisible with variant inheritance.
|
||||
const itemInitial = {
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
};
|
||||
|
||||
const itemAnimate = {
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
};
|
||||
|
||||
const itemTransition = {
|
||||
type: "spring" as const,
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
opacity: { duration: 0.2 },
|
||||
filter: { duration: 0.15 },
|
||||
};
|
||||
|
||||
const reducedItemInitial = { opacity: 0 };
|
||||
const reducedItemAnimate = { opacity: 1 };
|
||||
const reducedItemTransition = { duration: 0.15 };
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
librarySort: LibraryAgentSort;
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
selectedFolderId: string | null;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export function LibraryAgentList({
|
||||
searchTerm,
|
||||
librarySort,
|
||||
setLibrarySort,
|
||||
selectedFolderId,
|
||||
onFolderSelect,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: Props) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const activeContainerVariants = shouldReduceMotion
|
||||
? reducedContainerVariants
|
||||
: containerVariants;
|
||||
const activeInitial = shouldReduceMotion ? reducedItemInitial : itemInitial;
|
||||
const activeAnimate = shouldReduceMotion ? reducedItemAnimate : itemAnimate;
|
||||
const activeTransition = shouldReduceMotion
|
||||
? reducedItemTransition
|
||||
: itemTransition;
|
||||
|
||||
const {
|
||||
isFavoritesTab,
|
||||
agentLoading,
|
||||
agentCount,
|
||||
agents,
|
||||
allAgents: agents,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
foldersData,
|
||||
currentFolder,
|
||||
showFolders,
|
||||
editingFolder,
|
||||
setEditingFolder,
|
||||
deletingFolder,
|
||||
setDeletingFolder,
|
||||
handleAgentDrop,
|
||||
handleFolderDeleted,
|
||||
} = useLibraryAgentList({
|
||||
searchTerm,
|
||||
librarySort,
|
||||
selectedFolderId,
|
||||
onFolderSelect,
|
||||
activeTab,
|
||||
});
|
||||
} = useLibraryAgentList({ searchTerm, librarySort });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -132,42 +32,11 @@ export function LibraryAgentList({
|
||||
agentCount={agentCount}
|
||||
setLibrarySort={setLibrarySort}
|
||||
/>
|
||||
{!selectedFolderId && (
|
||||
<LibrarySubSection
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{selectedFolderId && (
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => onFolderSelect(null)}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back to Library
|
||||
</Button>
|
||||
{currentFolder && (
|
||||
<Text variant="h4" className="text-zinc-700">
|
||||
{currentFolder.icon} {currentFolder.name}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-2">
|
||||
{agentLoading ? (
|
||||
<div className="flex h-[200px] items-center justify-center">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
) : isFavoritesTab && agents.length === 0 ? (
|
||||
<div className="flex h-[200px] flex-col items-center justify-center gap-2 text-zinc-500">
|
||||
<HeartIcon className="h-10 w-10" />
|
||||
<Text variant="body">No favorite agents yet</Text>
|
||||
</div>
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
@@ -175,85 +44,14 @@ export function LibraryAgentList({
|
||||
hasNextPage={hasNextPage}
|
||||
loader={<LoadingSpinner size="medium" />}
|
||||
>
|
||||
<LayoutGroup>
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={`${activeTab}-${selectedFolderId || "all"}`}
|
||||
className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
variants={activeContainerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
exit="exit"
|
||||
>
|
||||
{showFolders &&
|
||||
foldersData?.folders.map((folder, i) => (
|
||||
<motion.div
|
||||
key={folder.id}
|
||||
initial={activeInitial}
|
||||
animate={activeAnimate}
|
||||
transition={{
|
||||
...activeTransition,
|
||||
delay: i * 0.04,
|
||||
}}
|
||||
>
|
||||
<LibraryFolder
|
||||
id={folder.id}
|
||||
name={folder.name}
|
||||
agentCount={folder.agent_count ?? 0}
|
||||
color={folder.color ?? undefined}
|
||||
icon={folder.icon ?? "📁"}
|
||||
onAgentDrop={handleAgentDrop}
|
||||
onClick={() => onFolderSelect(folder.id)}
|
||||
onEdit={() => setEditingFolder(folder)}
|
||||
onDelete={() => setDeletingFolder(folder)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
{agents.map((agent, i) => (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
initial={activeInitial}
|
||||
animate={activeAnimate}
|
||||
transition={{
|
||||
...activeTransition,
|
||||
delay:
|
||||
((showFolders
|
||||
? (foldersData?.folders.length ?? 0)
|
||||
: 0) +
|
||||
i) *
|
||||
0.04,
|
||||
}}
|
||||
>
|
||||
<LibraryAgentCard agent={agent} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</LayoutGroup>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{agents.map((agent) => (
|
||||
<LibraryAgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingFolder && (
|
||||
<LibraryFolderEditDialog
|
||||
folder={editingFolder}
|
||||
isOpen={!!editingFolder}
|
||||
setIsOpen={(open) => {
|
||||
if (!open) setEditingFolder(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deletingFolder && (
|
||||
<LibraryFolderDeleteDialog
|
||||
folder={deletingFolder}
|
||||
isOpen={!!deletingFolder}
|
||||
setIsOpen={(open) => {
|
||||
if (!open) setDeletingFolder(null);
|
||||
}}
|
||||
onDeleted={handleFolderDeleted}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import {
|
||||
useGetV2ListLibraryFolders,
|
||||
usePostV2BulkMoveAgents,
|
||||
getGetV2ListLibraryFoldersQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/folders/folders";
|
||||
import type { getV2ListLibraryFoldersResponseSuccess } from "@/app/api/__generated__/endpoints/folders/folders";
|
||||
import type { LibraryFolder } from "@/app/api/__generated__/models/libraryFolder";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import {
|
||||
okData,
|
||||
getPaginatedTotalCount,
|
||||
getPaginationNextPageNumber,
|
||||
unpaginate,
|
||||
} from "@/app/api/helpers";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
librarySort: LibraryAgentSort;
|
||||
selectedFolderId: string | null;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
export function useLibraryAgentList({
|
||||
searchTerm,
|
||||
librarySort,
|
||||
selectedFolderId,
|
||||
onFolderSelect,
|
||||
activeTab,
|
||||
}: Props) {
|
||||
const isFavoritesTab = activeTab === "favorites";
|
||||
const { toast } = useToast();
|
||||
const stableQueryClient = getQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
|
||||
const queryClient = getQueryClient();
|
||||
const prevSortRef = useRef<LibraryAgentSort | null>(null);
|
||||
|
||||
const [editingFolder, setEditingFolder] = useState<LibraryFolder | null>(
|
||||
null,
|
||||
);
|
||||
const [deletingFolder, setDeletingFolder] = useState<LibraryFolder | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const {
|
||||
data: agentsQueryData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading: allAgentsLoading,
|
||||
isLoading: agentLoading,
|
||||
} = useGetV2ListLibraryAgentsInfinite(
|
||||
{
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
search_term: searchTerm || undefined,
|
||||
sort_by: librarySort,
|
||||
folder_id: selectedFolderId ?? undefined,
|
||||
include_root_only: selectedFolderId === null ? true : undefined,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
@@ -72,147 +39,28 @@ export function useLibraryAgentList({
|
||||
},
|
||||
);
|
||||
|
||||
// Reset queries when sort changes to ensure fresh data with correct sorting
|
||||
useEffect(() => {
|
||||
if (prevSortRef.current !== null && prevSortRef.current !== librarySort) {
|
||||
stableQueryClient.resetQueries({
|
||||
// Reset all library agent queries to ensure fresh fetch with new sort
|
||||
queryClient.resetQueries({
|
||||
queryKey: ["/api/library/agents"],
|
||||
});
|
||||
}
|
||||
prevSortRef.current = librarySort;
|
||||
}, [librarySort, stableQueryClient]);
|
||||
}, [librarySort, queryClient]);
|
||||
|
||||
const allAgentsList = agentsQueryData
|
||||
const allAgents = agentsQueryData
|
||||
? unpaginate(agentsQueryData, "agents")
|
||||
: [];
|
||||
const allAgentsCount = getPaginatedTotalCount(agentsQueryData);
|
||||
|
||||
const favoriteAgentsData = useFavoriteAgents({ searchTerm });
|
||||
|
||||
const {
|
||||
agentLoading,
|
||||
agentCount,
|
||||
allAgents: agents,
|
||||
hasNextPage: agentsHasNextPage,
|
||||
isFetchingNextPage: agentsIsFetchingNextPage,
|
||||
fetchNextPage: agentsFetchNextPage,
|
||||
} = isFavoritesTab
|
||||
? favoriteAgentsData
|
||||
: {
|
||||
agentLoading: allAgentsLoading,
|
||||
agentCount: allAgentsCount,
|
||||
allAgents: allAgentsList,
|
||||
hasNextPage: hasNextPage,
|
||||
isFetchingNextPage: isFetchingNextPage,
|
||||
fetchNextPage: fetchNextPage,
|
||||
};
|
||||
|
||||
const { data: rawFoldersData } = useGetV2ListLibraryFolders(undefined, {
|
||||
query: { select: okData },
|
||||
});
|
||||
|
||||
const foldersData = searchTerm ? undefined : rawFoldersData;
|
||||
|
||||
const { mutate: moveAgentToFolder } = usePostV2BulkMoveAgents({
|
||||
mutation: {
|
||||
onMutate: async ({ data }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||
});
|
||||
|
||||
const previousFolders =
|
||||
queryClient.getQueriesData<getV2ListLibraryFoldersResponseSuccess>({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
|
||||
if (data.folder_id) {
|
||||
queryClient.setQueriesData<getV2ListLibraryFoldersResponseSuccess>(
|
||||
{ queryKey: getGetV2ListLibraryFoldersQueryKey() },
|
||||
(old) => {
|
||||
if (!old?.data?.folders) return old;
|
||||
return {
|
||||
...old,
|
||||
data: {
|
||||
...old.data,
|
||||
folders: old.data.folders.map((f) =>
|
||||
f.id === data.folder_id
|
||||
? {
|
||||
...f,
|
||||
agent_count:
|
||||
(f.agent_count ?? 0) + data.agent_ids.length,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { previousFolders };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previousFolders) {
|
||||
for (const [queryKey, data] of context.previousFolders) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to move agent. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleAgentDrop(agentId: string, folderId: string) {
|
||||
moveAgentToFolder({
|
||||
data: {
|
||||
agent_ids: [agentId],
|
||||
folder_id: folderId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const currentFolder = selectedFolderId
|
||||
? foldersData?.folders.find((f) => f.id === selectedFolderId)
|
||||
: null;
|
||||
|
||||
const showFolders = !isFavoritesTab && !selectedFolderId;
|
||||
|
||||
function handleFolderDeleted() {
|
||||
if (selectedFolderId === deletingFolder?.id) {
|
||||
onFolderSelect(null);
|
||||
}
|
||||
}
|
||||
const agentCount = getPaginatedTotalCount(agentsQueryData);
|
||||
|
||||
return {
|
||||
isFavoritesTab,
|
||||
allAgents,
|
||||
agentLoading,
|
||||
hasNextPage,
|
||||
agentCount,
|
||||
agents,
|
||||
hasNextPage: agentsHasNextPage,
|
||||
isFetchingNextPage: agentsIsFetchingNextPage,
|
||||
fetchNextPage: agentsFetchNextPage,
|
||||
foldersData,
|
||||
currentFolder,
|
||||
showFolders,
|
||||
editingFolder,
|
||||
setEditingFolder,
|
||||
deletingFolder,
|
||||
setDeletingFolder,
|
||||
handleAgentDrop,
|
||||
handleFolderDeleted,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
type FolderSize = "xs" | "sm" | "md" | "lg" | "xl";
|
||||
export type FolderColorName =
|
||||
| "neutral"
|
||||
| "slate"
|
||||
| "zinc"
|
||||
| "stone"
|
||||
| "red"
|
||||
| "orange"
|
||||
| "amber"
|
||||
| "yellow"
|
||||
| "lime"
|
||||
| "green"
|
||||
| "emerald"
|
||||
| "teal"
|
||||
| "cyan"
|
||||
| "sky"
|
||||
| "blue"
|
||||
| "indigo"
|
||||
| "violet"
|
||||
| "purple"
|
||||
| "fuchsia"
|
||||
| "pink"
|
||||
| "rose";
|
||||
|
||||
export type FolderColor = FolderColorName | (string & {});
|
||||
|
||||
const hexToColorName: Record<string, FolderColorName> = {
|
||||
"#3B82F6": "blue",
|
||||
"#3b82f6": "blue",
|
||||
"#A855F7": "purple",
|
||||
"#a855f7": "purple",
|
||||
"#10B981": "emerald",
|
||||
"#10b981": "emerald",
|
||||
"#F97316": "orange",
|
||||
"#f97316": "orange",
|
||||
"#EC4899": "pink",
|
||||
"#ec4899": "pink",
|
||||
};
|
||||
|
||||
export function resolveColor(color: FolderColor | undefined): FolderColorName {
|
||||
if (!color) return "blue";
|
||||
if (color in hexToColorName) return hexToColorName[color];
|
||||
if (color in colorMap) return color as FolderColorName;
|
||||
return "blue";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
size?: FolderSize | number;
|
||||
color?: FolderColor;
|
||||
icon?: string;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
const sizeMap: Record<FolderSize, number> = {
|
||||
xs: 0.4,
|
||||
sm: 0.75,
|
||||
md: 1,
|
||||
lg: 1.25,
|
||||
xl: 1.5,
|
||||
};
|
||||
|
||||
const colorMap: Record<
|
||||
FolderColorName,
|
||||
{
|
||||
bg: string;
|
||||
border: string;
|
||||
borderLight: string;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
}
|
||||
> = {
|
||||
neutral: {
|
||||
bg: "bg-neutral-300",
|
||||
border: "border-neutral-300",
|
||||
borderLight: "border-neutral-200",
|
||||
fill: "fill-neutral-300",
|
||||
stroke: "stroke-neutral-400",
|
||||
},
|
||||
slate: {
|
||||
bg: "bg-slate-300",
|
||||
border: "border-slate-300",
|
||||
borderLight: "border-slate-200",
|
||||
fill: "fill-slate-300",
|
||||
stroke: "stroke-slate-400",
|
||||
},
|
||||
zinc: {
|
||||
bg: "bg-zinc-300",
|
||||
border: "border-zinc-300",
|
||||
borderLight: "border-zinc-200",
|
||||
fill: "fill-zinc-300",
|
||||
stroke: "stroke-zinc-400",
|
||||
},
|
||||
stone: {
|
||||
bg: "bg-stone-300",
|
||||
border: "border-stone-300",
|
||||
borderLight: "border-stone-200",
|
||||
fill: "fill-stone-300",
|
||||
stroke: "stroke-stone-400",
|
||||
},
|
||||
red: {
|
||||
bg: "bg-red-300",
|
||||
border: "border-red-300",
|
||||
borderLight: "border-red-200",
|
||||
fill: "fill-red-300",
|
||||
stroke: "stroke-red-400",
|
||||
},
|
||||
orange: {
|
||||
bg: "bg-orange-200",
|
||||
border: "border-orange-200",
|
||||
borderLight: "border-orange-200",
|
||||
fill: "fill-orange-200",
|
||||
stroke: "stroke-orange-400",
|
||||
},
|
||||
amber: {
|
||||
bg: "bg-amber-200",
|
||||
border: "border-amber-200",
|
||||
borderLight: "border-amber-200",
|
||||
fill: "fill-amber-200",
|
||||
stroke: "stroke-amber-400",
|
||||
},
|
||||
yellow: {
|
||||
bg: "bg-yellow-200",
|
||||
border: "border-yellow-200",
|
||||
borderLight: "border-yellow-200",
|
||||
fill: "fill-yellow-200",
|
||||
stroke: "stroke-yellow-400",
|
||||
},
|
||||
lime: {
|
||||
bg: "bg-lime-300",
|
||||
border: "border-lime-300",
|
||||
borderLight: "border-lime-200",
|
||||
fill: "fill-lime-300",
|
||||
stroke: "stroke-lime-400",
|
||||
},
|
||||
green: {
|
||||
bg: "bg-green-200",
|
||||
border: "border-green-200",
|
||||
borderLight: "border-green-200",
|
||||
fill: "fill-green-200",
|
||||
stroke: "stroke-green-400",
|
||||
},
|
||||
emerald: {
|
||||
bg: "bg-emerald-300",
|
||||
border: "border-emerald-300",
|
||||
borderLight: "border-emerald-200",
|
||||
fill: "fill-emerald-300",
|
||||
stroke: "stroke-emerald-400",
|
||||
},
|
||||
teal: {
|
||||
bg: "bg-teal-300",
|
||||
border: "border-teal-300",
|
||||
borderLight: "border-teal-200",
|
||||
fill: "fill-teal-300",
|
||||
stroke: "stroke-teal-400",
|
||||
},
|
||||
cyan: {
|
||||
bg: "bg-cyan-300",
|
||||
border: "border-cyan-300",
|
||||
borderLight: "border-cyan-200",
|
||||
fill: "fill-cyan-300",
|
||||
stroke: "stroke-cyan-400",
|
||||
},
|
||||
sky: {
|
||||
bg: "bg-sky-300",
|
||||
border: "border-sky-300",
|
||||
borderLight: "border-sky-200",
|
||||
fill: "fill-sky-300",
|
||||
stroke: "stroke-sky-400",
|
||||
},
|
||||
blue: {
|
||||
bg: "bg-blue-300",
|
||||
border: "border-blue-300",
|
||||
borderLight: "border-blue-200",
|
||||
fill: "fill-blue-300",
|
||||
stroke: "stroke-blue-400",
|
||||
},
|
||||
indigo: {
|
||||
bg: "bg-indigo-300",
|
||||
border: "border-indigo-300",
|
||||
borderLight: "border-indigo-200",
|
||||
fill: "fill-indigo-300",
|
||||
stroke: "stroke-indigo-400",
|
||||
},
|
||||
violet: {
|
||||
bg: "bg-violet-300",
|
||||
border: "border-violet-300",
|
||||
borderLight: "border-violet-200",
|
||||
fill: "fill-violet-300",
|
||||
stroke: "stroke-violet-400",
|
||||
},
|
||||
purple: {
|
||||
bg: "bg-purple-200",
|
||||
border: "border-purple-200",
|
||||
borderLight: "border-purple-200",
|
||||
fill: "fill-purple-200",
|
||||
stroke: "stroke-purple-400",
|
||||
},
|
||||
fuchsia: {
|
||||
bg: "bg-fuchsia-300",
|
||||
border: "border-fuchsia-300",
|
||||
borderLight: "border-fuchsia-200",
|
||||
fill: "fill-fuchsia-300",
|
||||
stroke: "stroke-fuchsia-400",
|
||||
},
|
||||
pink: {
|
||||
bg: "bg-pink-300",
|
||||
border: "border-pink-300",
|
||||
borderLight: "border-pink-200",
|
||||
fill: "fill-pink-300",
|
||||
stroke: "stroke-pink-400",
|
||||
},
|
||||
rose: {
|
||||
bg: "bg-rose-300",
|
||||
border: "border-rose-300",
|
||||
borderLight: "border-rose-200",
|
||||
fill: "fill-rose-300",
|
||||
stroke: "stroke-rose-400",
|
||||
},
|
||||
};
|
||||
|
||||
// Card-level bg (50) and border (200) classes per folder color
|
||||
export const folderCardStyles: Record<
|
||||
FolderColorName,
|
||||
{ bg: string; border: string }
|
||||
> = {
|
||||
neutral: { bg: "bg-neutral-50", border: "border-neutral-200" },
|
||||
slate: { bg: "bg-slate-50", border: "border-slate-200" },
|
||||
zinc: { bg: "bg-zinc-50", border: "border-zinc-200" },
|
||||
stone: { bg: "bg-stone-50", border: "border-stone-200" },
|
||||
red: { bg: "bg-red-50", border: "border-red-200" },
|
||||
orange: { bg: "bg-orange-50", border: "border-orange-200" },
|
||||
amber: { bg: "bg-amber-50", border: "border-amber-200" },
|
||||
yellow: { bg: "bg-yellow-50", border: "border-yellow-200" },
|
||||
lime: { bg: "bg-lime-50", border: "border-lime-200" },
|
||||
green: { bg: "bg-green-50", border: "border-green-200" },
|
||||
emerald: { bg: "bg-emerald-50", border: "border-emerald-200" },
|
||||
teal: { bg: "bg-teal-50", border: "border-teal-200" },
|
||||
cyan: { bg: "bg-cyan-50", border: "border-cyan-200" },
|
||||
sky: { bg: "bg-sky-50", border: "border-sky-200" },
|
||||
blue: { bg: "bg-blue-50", border: "border-blue-200" },
|
||||
indigo: { bg: "bg-indigo-50", border: "border-indigo-200" },
|
||||
violet: { bg: "bg-violet-50", border: "border-violet-200" },
|
||||
purple: { bg: "bg-purple-50", border: "border-purple-200" },
|
||||
fuchsia: { bg: "bg-fuchsia-50", border: "border-fuchsia-200" },
|
||||
pink: { bg: "bg-pink-50", border: "border-pink-200" },
|
||||
rose: { bg: "bg-rose-50", border: "border-rose-200" },
|
||||
};
|
||||
|
||||
export function FolderIcon({
|
||||
className = "",
|
||||
size = "xs",
|
||||
color = "blue",
|
||||
icon,
|
||||
isOpen = false,
|
||||
}: Props) {
|
||||
const scale = typeof size === "number" ? size : sizeMap[size];
|
||||
const resolvedColor = resolveColor(color);
|
||||
const colors = colorMap[resolvedColor];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative cursor-pointer ${className}`}
|
||||
style={{
|
||||
width: 320 * scale,
|
||||
height: 208 * scale,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-52 w-80 origin-top-left"
|
||||
style={{ transform: `scale(${scale})`, perspective: "500px" }}
|
||||
>
|
||||
<div
|
||||
className={`folder-back relative mx-auto flex h-full w-[87.5%] justify-center overflow-visible rounded-3xl ${colors.bg} ${colors.border}`}
|
||||
>
|
||||
{[
|
||||
{
|
||||
initial: { rotate: -3, x: -38, y: 2 },
|
||||
open: { rotate: -8, x: -70, y: -75 },
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
bounce: 0.15,
|
||||
stiffness: 160,
|
||||
damping: 22,
|
||||
},
|
||||
className: "z-10",
|
||||
},
|
||||
{
|
||||
initial: { rotate: 0, x: 0, y: 0 },
|
||||
open: { rotate: 1, x: 2, y: -95 },
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
duration: 0.55,
|
||||
bounce: 0.12,
|
||||
stiffness: 190,
|
||||
damping: 24,
|
||||
},
|
||||
className: "z-20",
|
||||
},
|
||||
{
|
||||
initial: { rotate: 3.5, x: 42, y: 1 },
|
||||
open: { rotate: 9, x: 75, y: -80 },
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
duration: 0.58,
|
||||
bounce: 0.17,
|
||||
stiffness: 170,
|
||||
damping: 21,
|
||||
},
|
||||
className: "z-10",
|
||||
},
|
||||
].map((page, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={page.initial}
|
||||
animate={isOpen ? page.open : page.initial}
|
||||
transition={page.transition}
|
||||
className={`absolute top-2 h-fit w-32 rounded-xl shadow-lg ${page.className}`}
|
||||
>
|
||||
<Page color={resolvedColor} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
rotateX: isOpen ? -15 : 0,
|
||||
}}
|
||||
transition={{ type: "spring", duration: 0.5, bounce: 0.25 }}
|
||||
className="absolute inset-x-0 -bottom-px z-30 mx-auto flex h-44 w-[87.5%] origin-bottom items-end justify-center overflow-visible"
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
<svg
|
||||
className="h-auto w-full"
|
||||
viewBox="0 0 173 109"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
className={`${colors.fill} ${colors.stroke}`}
|
||||
d="M15.0423 0.500003C0.5 0.500009 0.5 14.2547 0.5 14.2547V92.5C0.5 101.337 7.66344 108.5 16.5 108.5H156.5C165.337 108.5 172.5 101.337 172.5 92.5V34.3302C172.5 25.4936 165.355 18.3302 156.519 18.3302H108.211C98.1341 18.3302 91.2921 5.57144 82.0156 1.63525C80.3338 0.921645 78.2634 0.500002 75.7187 0.500003H15.0423Z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-7xl">
|
||||
{icon}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
color: FolderColorName;
|
||||
}
|
||||
|
||||
function Page({ color = "blue" }: PageProps) {
|
||||
const colors = colorMap[color];
|
||||
return (
|
||||
<div
|
||||
className={`h-full w-full rounded-xl border bg-white p-4 ${colors.borderLight}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text variant="h5" className="text-black">
|
||||
agent.json
|
||||
</Text>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<div className="h-1.5 flex-1 rounded-full bg-neutral-100" />
|
||||
<div className="h-1.5 flex-1 rounded-full bg-neutral-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
FolderIcon,
|
||||
FolderColor,
|
||||
folderCardStyles,
|
||||
resolveColor,
|
||||
} from "./FolderIcon";
|
||||
import { useState } from "react";
|
||||
import { PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
agentCount: number;
|
||||
color?: FolderColor;
|
||||
icon: string;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onAgentDrop?: (agentId: string, folderId: string) => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function LibraryFolder({
|
||||
id,
|
||||
name,
|
||||
agentCount,
|
||||
color,
|
||||
icon,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAgentDrop,
|
||||
onClick,
|
||||
}: Props) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const resolvedColor = resolveColor(color);
|
||||
const cardStyle = folderCardStyles[resolvedColor];
|
||||
|
||||
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
|
||||
if (e.dataTransfer.types.includes("application/agent-id")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const agentId = e.dataTransfer.getData("application/agent-id");
|
||||
if (agentId && onAgentDrop) {
|
||||
onAgentDrop(agentId, id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="library-folder"
|
||||
data-folder-id={id}
|
||||
className={`group relative inline-flex h-[10.625rem] w-full max-w-[25rem] cursor-pointer flex-col items-start justify-between gap-2.5 rounded-medium border p-4 transition-all duration-200 hover:shadow-md ${
|
||||
isDragOver
|
||||
? "border-blue-400 bg-blue-50 ring-2 ring-blue-200"
|
||||
: `${cardStyle.border} ${cardStyle.bg}`
|
||||
}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
{/* Left side - Folder name and agent count */}
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Text
|
||||
variant="h5"
|
||||
data-testid="library-folder-name"
|
||||
className="line-clamp-2 hyphens-auto break-words"
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
variant="small"
|
||||
className="text-zinc-500"
|
||||
data-testid="library-folder-agent-count"
|
||||
>
|
||||
{agentCount} {agentCount === 1 ? "agent" : "agents"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Right side - Custom folder icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<FolderIcon isOpen={isHovered} color={color} icon={icon} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons - visible on hover */}
|
||||
<div
|
||||
className="flex items-center justify-end gap-2"
|
||||
data-testid="library-folder-actions"
|
||||
>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Edit agent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.();
|
||||
}}
|
||||
className="h-8 w-8 p-2"
|
||||
>
|
||||
<PencilSimpleIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Delete agent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.();
|
||||
}}
|
||||
className="h-8 w-8 p-2 hover:border-red-300 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/molecules/Form/Form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { FolderSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { EmojiPicker } from "@ferrucc-io/emoji-picker";
|
||||
import {
|
||||
usePostV2CreateFolder,
|
||||
getGetV2ListLibraryFoldersQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/folders/folders";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const FOLDER_COLORS = [
|
||||
{ value: "#3B82F6", label: "Blue" },
|
||||
{ value: "#A855F7", label: "Purple" },
|
||||
{ value: "#10B981", label: "Green" },
|
||||
{ value: "#F97316", label: "Orange" },
|
||||
{ value: "#EC4899", label: "Pink" },
|
||||
];
|
||||
|
||||
export const libraryFolderCreationFormSchema = z.object({
|
||||
folderName: z.string().min(1, "Folder name is required"),
|
||||
folderColor: z.string().min(1, "Folder color is required"),
|
||||
folderIcon: z.string().min(1, "Folder icon is required"),
|
||||
});
|
||||
|
||||
export default function LibraryFolderCreationDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutate: createFolder, isPending } = usePostV2CreateFolder({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
setIsOpen(false);
|
||||
form.reset();
|
||||
toast({
|
||||
title: "Folder created",
|
||||
description: "Your folder has been created successfully.",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to create folder. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof libraryFolderCreationFormSchema>>({
|
||||
resolver: zodResolver(libraryFolderCreationFormSchema),
|
||||
defaultValues: {
|
||||
folderName: "",
|
||||
folderColor: "",
|
||||
folderIcon: "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof libraryFolderCreationFormSchema>) {
|
||||
createFolder({
|
||||
data: {
|
||||
name: values.folderName.trim(),
|
||||
color: values.folderColor,
|
||||
icon: values.folderIcon,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Create Folder"
|
||||
styling={{ maxWidth: "30rem" }}
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: setIsOpen,
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
data-testid="upload-agent-button"
|
||||
variant="secondary"
|
||||
className="h-fit w-fit"
|
||||
size="small"
|
||||
>
|
||||
<FolderSimpleIcon width={18} height={18} />
|
||||
<span className="create-folder">Create folder</span>
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={(values) => onSubmit(values)}
|
||||
className="flex flex-col justify-center gap-2 px-1"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
label="Folder name"
|
||||
placeholder="Enter folder name"
|
||||
className="!mb-0 w-full"
|
||||
wrapperClassName="!mb-0"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
id="folderColor"
|
||||
label="Folder color"
|
||||
placeholder="Select a color"
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={FOLDER_COLORS.map((color) => ({
|
||||
value: color.value,
|
||||
label: color.label,
|
||||
icon: (
|
||||
<div
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: color.value }}
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
wrapperClassName="!mb-0"
|
||||
renderItem={(option) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderIcon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text variant="large-medium" as="span" className="text-black">
|
||||
Folder icon
|
||||
</Text>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Text variant="small" className="text-zinc-500">
|
||||
Selected:
|
||||
</Text>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-2xl">
|
||||
{form.watch("folderIcon") || (
|
||||
<span className="text-sm text-zinc-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[295px] w-full overflow-hidden">
|
||||
<EmojiPicker
|
||||
onEmojiSelect={(emoji) => {
|
||||
field.onChange(emoji);
|
||||
}}
|
||||
emojiSize={32}
|
||||
className="w-full rounded-2xl px-2"
|
||||
>
|
||||
<EmojiPicker.Group>
|
||||
<EmojiPicker.List
|
||||
hideStickyHeader
|
||||
containerHeight={295}
|
||||
/>
|
||||
</EmojiPicker.Group>
|
||||
</EmojiPicker>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="mt-2 min-w-[18rem]"
|
||||
disabled={!form.formState.isValid || isPending}
|
||||
loading={isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import {
|
||||
useDeleteV2DeleteFolder,
|
||||
getGetV2ListLibraryFoldersQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/folders/folders";
|
||||
import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { LibraryFolder } from "@/app/api/__generated__/models/libraryFolder";
|
||||
|
||||
interface Props {
|
||||
folder: LibraryFolder;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function LibraryFolderDeleteDialog({
|
||||
folder,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
onDeleted,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutate: deleteFolder, isPending } = useDeleteV2DeleteFolder({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||
});
|
||||
toast({
|
||||
title: "Folder deleted",
|
||||
description: `"${folder.name}" has been deleted.`,
|
||||
});
|
||||
setIsOpen(false);
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete folder. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
deleteFolder({ folderId: folder.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: setIsOpen,
|
||||
}}
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
title="Delete folder"
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div>
|
||||
<Text variant="large">
|
||||
Are you sure you want to delete “{folder.name}”? Agents
|
||||
inside this folder will be moved back to your library.
|
||||
</Text>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
loading={isPending}
|
||||
>
|
||||
Delete Folder
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/molecules/Form/Form";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { EmojiPicker } from "@ferrucc-io/emoji-picker";
|
||||
import {
|
||||
usePatchV2UpdateFolder,
|
||||
getGetV2ListLibraryFoldersQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/folders/folders";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { LibraryFolder } from "@/app/api/__generated__/models/libraryFolder";
|
||||
import type { getV2ListLibraryFoldersResponseSuccess } from "@/app/api/__generated__/endpoints/folders/folders";
|
||||
import { ApiError } from "@/lib/autogpt-server-api/helpers";
|
||||
|
||||
const FOLDER_COLORS = [
|
||||
{ value: "#3B82F6", label: "Blue" },
|
||||
{ value: "#A855F7", label: "Purple" },
|
||||
{ value: "#10B981", label: "Green" },
|
||||
{ value: "#F97316", label: "Orange" },
|
||||
{ value: "#EC4899", label: "Pink" },
|
||||
];
|
||||
|
||||
const editFolderSchema = z.object({
|
||||
folderName: z.string().min(1, "Folder name is required"),
|
||||
folderColor: z.string().min(1, "Folder color is required"),
|
||||
folderIcon: z.string().min(1, "Folder icon is required"),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
folder: LibraryFolder;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function LibraryFolderEditDialog({ folder, isOpen, setIsOpen }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<z.infer<typeof editFolderSchema>>({
|
||||
resolver: zodResolver(editFolderSchema),
|
||||
defaultValues: {
|
||||
folderName: folder.name,
|
||||
folderColor: folder.color ?? "",
|
||||
folderIcon: folder.icon ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
form.reset({
|
||||
folderName: folder.name,
|
||||
folderColor: folder.color ?? "",
|
||||
folderIcon: folder.icon ?? "",
|
||||
});
|
||||
}
|
||||
}, [isOpen, folder, form]);
|
||||
|
||||
const { mutate: updateFolder, isPending } = usePatchV2UpdateFolder({
|
||||
mutation: {
|
||||
onMutate: async ({ folderId, data }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
|
||||
const previousData =
|
||||
queryClient.getQueriesData<getV2ListLibraryFoldersResponseSuccess>({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
|
||||
queryClient.setQueriesData<getV2ListLibraryFoldersResponseSuccess>(
|
||||
{ queryKey: getGetV2ListLibraryFoldersQueryKey() },
|
||||
(old) => {
|
||||
if (!old?.data?.folders) return old;
|
||||
return {
|
||||
...old,
|
||||
data: {
|
||||
...old.data,
|
||||
folders: old.data.folders.map((f) =>
|
||||
f.id === folderId
|
||||
? {
|
||||
...f,
|
||||
name: data.name ?? f.name,
|
||||
color: data.color ?? f.color,
|
||||
icon: data.icon ?? f.icon,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
onError: (error: unknown, _variables, context) => {
|
||||
if (context?.previousData) {
|
||||
for (const [queryKey, data] of context.previousData) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
const detail = (error.response as any)?.detail ?? "";
|
||||
if (
|
||||
typeof detail === "string" &&
|
||||
detail.toLowerCase().includes("already exists")
|
||||
) {
|
||||
form.setError("folderName", {
|
||||
message: "A folder with this name already exists",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update folder. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsOpen(false);
|
||||
toast({
|
||||
title: "Folder updated",
|
||||
description: "Your folder has been updated successfully.",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof editFolderSchema>) {
|
||||
updateFolder({
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: values.folderName.trim(),
|
||||
color: values.folderColor,
|
||||
icon: values.folderIcon,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Edit Folder"
|
||||
styling={{ maxWidth: "30rem" }}
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: setIsOpen,
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={(values) => onSubmit(values)}
|
||||
className="flex flex-col justify-center gap-2 px-1"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
label="Folder name"
|
||||
placeholder="Enter folder name"
|
||||
className="w-full"
|
||||
wrapperClassName="!mb-0"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
id="folderColor"
|
||||
label="Folder color"
|
||||
placeholder="Select a color"
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
wrapperClassName="!mb-0"
|
||||
options={FOLDER_COLORS.map((color) => ({
|
||||
value: color.value,
|
||||
label: color.label,
|
||||
icon: (
|
||||
<div
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: color.value }}
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
renderItem={(option) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderIcon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text variant="large-medium" as="span" className="text-black">
|
||||
Folder icon
|
||||
</Text>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Text variant="small" className="text-zinc-500">
|
||||
Selected:
|
||||
</Text>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-2xl">
|
||||
{form.watch("folderIcon") || (
|
||||
<span className="text-sm text-zinc-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[295px] w-full overflow-hidden">
|
||||
<EmojiPicker
|
||||
onEmojiSelect={(emoji) => {
|
||||
field.onChange(emoji);
|
||||
}}
|
||||
emojiSize={32}
|
||||
className="w-full rounded-2xl px-2"
|
||||
>
|
||||
<EmojiPicker.Group className="pt-2">
|
||||
<EmojiPicker.List
|
||||
hideStickyHeader
|
||||
containerHeight={295}
|
||||
/>
|
||||
</EmojiPicker.Group>
|
||||
</EmojiPicker>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="mt-2 min-w-[18rem]"
|
||||
disabled={!form.formState.isValid || isPending}
|
||||
loading={isPending}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import LibraryFolderCreationDialog from "../LibraryFolderCreationDialog/LibraryFolderCreationDialog";
|
||||
import { LibraryTabs, Tab } from "../LibraryTabs/LibraryTabs";
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export function LibrarySubSection({ tabs, activeTab, onTabChange }: Props) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<LibraryTabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
<LibraryFolderCreationDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Icon } from "@phosphor-icons/react";
|
||||
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: Icon;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
layoutId?: string;
|
||||
}
|
||||
|
||||
export function LibraryTabs({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
layoutId = "library-tabs",
|
||||
}: Props) {
|
||||
const { registerFavoritesTabRef } = useFavoriteAnimation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={activeTab === tab.id}
|
||||
onSelect={onTabChange}
|
||||
layoutId={layoutId}
|
||||
onRefReady={
|
||||
tab.id === "favorites" ? registerFavoritesTabRef : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabButtonProps {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
onSelect: (tabId: string) => void;
|
||||
layoutId: string;
|
||||
onRefReady?: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
tab,
|
||||
isActive,
|
||||
onSelect,
|
||||
layoutId,
|
||||
onRefReady,
|
||||
}: TabButtonProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && !isLoaded) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}, [isActive, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onRefReady) {
|
||||
onRefReady(buttonRef.current);
|
||||
}
|
||||
}, [onRefReady]);
|
||||
|
||||
const ButtonIcon = tab.icon;
|
||||
const activeColor = "text-primary";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={buttonRef}
|
||||
layoutId={`${layoutId}-button-${tab.id}`}
|
||||
transition={{
|
||||
layout: {
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
stiffness: 230,
|
||||
mass: 1.2,
|
||||
ease: [0.215, 0.61, 0.355, 1],
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelect(tab.id);
|
||||
setIsLoaded(true);
|
||||
}}
|
||||
className="flex h-fit w-fit"
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<motion.div
|
||||
layout
|
||||
transition={{
|
||||
layout: {
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
stiffness: 230,
|
||||
mass: 1.2,
|
||||
},
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-fit cursor-pointer items-center gap-1.5 overflow-hidden border border-zinc-200 px-3 py-2 text-black transition-colors duration-75 ease-out hover:border-zinc-300 hover:bg-zinc-300",
|
||||
isActive && activeColor,
|
||||
isActive ? "px-4" : "px-3",
|
||||
)}
|
||||
style={{
|
||||
borderRadius: "25px",
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
layoutId={`${layoutId}-icon-${tab.id}`}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ButtonIcon size={18} />
|
||||
</motion.div>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="flex items-center"
|
||||
initial={isLoaded ? { opacity: 0, filter: "blur(4px)" } : false}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
transition={{
|
||||
duration: isLoaded ? 0.2 : 0,
|
||||
ease: [0.86, 0, 0.07, 1],
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
layoutId={`${layoutId}-text-${tab.id}`}
|
||||
className="font-sans text-sm font-medium text-black"
|
||||
>
|
||||
{tab.title}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import {
|
||||
useGetV2ListLibraryFolders,
|
||||
usePostV2BulkMoveAgents,
|
||||
getGetV2ListLibraryFoldersQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/folders/folders";
|
||||
import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
currentFolderId?: string | null;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MoveToFolderDialog({
|
||||
agentId,
|
||||
agentName,
|
||||
currentFolderId,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: foldersData } = useGetV2ListLibraryFolders(undefined, {
|
||||
query: { select: okData },
|
||||
});
|
||||
|
||||
const { mutate: moveAgent, isPending } = usePostV2BulkMoveAgents({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||
});
|
||||
setIsOpen(false);
|
||||
setSearch("");
|
||||
toast({
|
||||
title: "Agent moved",
|
||||
description: `"${agentName}" has been moved.`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to move agent. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const folders = (foldersData?.folders ?? []).filter(
|
||||
(f) =>
|
||||
f.id !== currentFolderId &&
|
||||
f.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
function handleMoveToFolder(folderId: string) {
|
||||
moveAgent({
|
||||
data: {
|
||||
agent_ids: [agentId],
|
||||
folder_id: folderId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
controlled={{ isOpen, set: setIsOpen }}
|
||||
styling={{ maxWidth: "28rem" }}
|
||||
title="Move to folder"
|
||||
onClose={() => {
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
id="search-folders"
|
||||
label="Search folders"
|
||||
placeholder="Search folders..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="max-h-[280px] overflow-y-auto">
|
||||
{folders.length === 0 ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<Text variant="small" className="text-zinc-400">
|
||||
No folders found
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{folders.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3 px-3 py-2.5"
|
||||
disabled={isPending}
|
||||
onClick={() => handleMoveToFolder(folder.id)}
|
||||
>
|
||||
<span className="text-lg">{folder.icon ?? "📁"}</span>
|
||||
<div className="flex flex-col items-start">
|
||||
<Text variant="small-medium">{folder.name}</Text>
|
||||
<Text variant="small" className="text-zinc-400">
|
||||
{folder.agent_count ?? 0}{" "}
|
||||
{(folder.agent_count ?? 0) === 1 ? "agent" : "agents"}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { FlyingHeart } from "../components/FlyingHeart/FlyingHeart";
|
||||
|
||||
interface FavoriteAnimationContextType {
|
||||
triggerFavoriteAnimation: (startPosition: { x: number; y: number }) => void;
|
||||
registerFavoritesTabRef: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
const FavoriteAnimationContext =
|
||||
createContext<FavoriteAnimationContextType | null>(null);
|
||||
|
||||
interface FavoriteAnimationProviderProps {
|
||||
children: React.ReactNode;
|
||||
onAnimationComplete?: () => void;
|
||||
}
|
||||
|
||||
export function FavoriteAnimationProvider({
|
||||
children,
|
||||
onAnimationComplete,
|
||||
}: FavoriteAnimationProviderProps) {
|
||||
const [animationState, setAnimationState] = useState<{
|
||||
startPosition: { x: number; y: number } | null;
|
||||
targetPosition: { x: number; y: number } | null;
|
||||
}>({
|
||||
startPosition: null,
|
||||
targetPosition: null,
|
||||
});
|
||||
|
||||
const favoritesTabRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const registerFavoritesTabRef = useCallback((element: HTMLElement | null) => {
|
||||
favoritesTabRef.current = element;
|
||||
}, []);
|
||||
|
||||
const triggerFavoriteAnimation = useCallback(
|
||||
(startPosition: { x: number; y: number }) => {
|
||||
if (favoritesTabRef.current) {
|
||||
const rect = favoritesTabRef.current.getBoundingClientRect();
|
||||
const targetPosition = {
|
||||
x: rect.left + rect.width / 2 - 12,
|
||||
y: rect.top + rect.height / 2 - 12,
|
||||
};
|
||||
setAnimationState({ startPosition, targetPosition });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
function handleAnimationComplete() {
|
||||
setAnimationState({ startPosition: null, targetPosition: null });
|
||||
onAnimationComplete?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<FavoriteAnimationContext.Provider
|
||||
value={{ triggerFavoriteAnimation, registerFavoritesTabRef }}
|
||||
>
|
||||
{children}
|
||||
<FlyingHeart
|
||||
startPosition={animationState.startPosition}
|
||||
targetPosition={animationState.targetPosition}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
/>
|
||||
</FavoriteAnimationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFavoriteAnimation() {
|
||||
const context = useContext(FavoriteAnimationContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useFavoriteAnimation must be used within FavoriteAnimationProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { OldAgentLibraryView } from "../../agents/[id]/components/OldAgentLibraryView/OldAgentLibraryView";
|
||||
|
||||
export default function OldAgentLibraryPage() {
|
||||
return <OldAgentLibraryView />;
|
||||
}
|
||||
@@ -1,55 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { HeartIcon, ListIcon } from "@phosphor-icons/react";
|
||||
import { useEffect } from "react";
|
||||
import { FavoritesSection } from "./components/FavoritesSection/FavoritesSection";
|
||||
import { LibraryActionHeader } from "./components/LibraryActionHeader/LibraryActionHeader";
|
||||
import { LibraryAgentList } from "./components/LibraryAgentList/LibraryAgentList";
|
||||
import { Tab } from "./components/LibraryTabs/LibraryTabs";
|
||||
import { useLibraryListPage } from "./components/useLibraryListPage";
|
||||
import { FavoriteAnimationProvider } from "./context/FavoriteAnimationContext";
|
||||
|
||||
const LIBRARY_TABS: Tab[] = [
|
||||
{ id: "all", title: "All", icon: ListIcon },
|
||||
{ id: "favorites", title: "Favorites", icon: HeartIcon },
|
||||
];
|
||||
|
||||
export default function LibraryPage() {
|
||||
const { searchTerm, setSearchTerm, librarySort, setLibrarySort } =
|
||||
useLibraryListPage();
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(LIBRARY_TABS[0].id);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Library – AutoGPT Platform";
|
||||
}, []);
|
||||
|
||||
function handleTabChange(tabId: string) {
|
||||
setActiveTab(tabId);
|
||||
setSelectedFolderId(null);
|
||||
}
|
||||
|
||||
const handleFavoriteAnimationComplete = useCallback(() => {
|
||||
setActiveTab("favorites");
|
||||
setSelectedFolderId(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FavoriteAnimationProvider
|
||||
onAnimationComplete={handleFavoriteAnimationComplete}
|
||||
>
|
||||
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
|
||||
<LibraryActionHeader setSearchTerm={setSearchTerm} />
|
||||
<LibraryAgentList
|
||||
searchTerm={searchTerm}
|
||||
librarySort={librarySort}
|
||||
setLibrarySort={setLibrarySort}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onFolderSelect={setSelectedFolderId}
|
||||
tabs={LIBRARY_TABS}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
</main>
|
||||
</FavoriteAnimationProvider>
|
||||
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
|
||||
<LibraryActionHeader setSearchTerm={setSearchTerm} />
|
||||
<FavoritesSection searchTerm={searchTerm} />
|
||||
<LibraryAgentList
|
||||
searchTerm={searchTerm}
|
||||
librarySort={librarySort}
|
||||
setLibrarySort={setLibrarySort}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3589,29 +3589,6 @@
|
||||
"title": "Page Size"
|
||||
},
|
||||
"description": "Number of agents per page (must be >= 1)"
|
||||
},
|
||||
{
|
||||
"name": "folder_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"description": "Filter by folder ID",
|
||||
"title": "Folder Id"
|
||||
},
|
||||
"description": "Filter by folder ID"
|
||||
},
|
||||
{
|
||||
"name": "include_root_only",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"description": "Only return agents without a folder (root-level agents)",
|
||||
"default": false,
|
||||
"title": "Include Root Only"
|
||||
},
|
||||
"description": "Only return agents without a folder (root-level agents)"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3979,340 +3956,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/folders": {
|
||||
"get": {
|
||||
"tags": ["v2", "library", "folders", "private"],
|
||||
"summary": "List Library Folders",
|
||||
"description": "List folders for the authenticated user.\n\nArgs:\n user_id: ID of the authenticated user.\n parent_id: Optional parent folder ID to filter by.\n include_counts: Whether to include agent and subfolder counts.\n\nReturns:\n A FolderListResponse containing folders.",
|
||||
"operationId": "getV2List library folders",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "parent_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"description": "Filter by parent folder ID. If not provided, returns root-level folders.",
|
||||
"title": "Parent Id"
|
||||
},
|
||||
"description": "Filter by parent folder ID. If not provided, returns root-level folders."
|
||||
},
|
||||
{
|
||||
"name": "include_counts",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"description": "Include agent and subfolder counts",
|
||||
"default": true,
|
||||
"title": "Include Counts"
|
||||
},
|
||||
"description": "Include agent and subfolder counts"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of folders",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/FolderListResponse" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["v2", "library", "folders", "private"],
|
||||
"summary": "Create Folder",
|
||||
"description": "Create a new folder.\n\nArgs:\n payload: The folder creation request.\n user_id: ID of the authenticated user.\n\nReturns:\n The created LibraryFolder.",
|
||||
"operationId": "postV2Create folder",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/FolderCreateRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Folder created successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error" },
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"404": { "description": "Parent folder not found" },
|
||||
"409": { "description": "Folder name conflict" },
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/folders/agents/bulk-move": {
|
||||
"post": {
|
||||
"tags": ["v2", "library", "folders", "private"],
|
||||
"summary": "Bulk Move Agents",
|
||||
"description": "Move multiple agents to a folder.\n\nArgs:\n payload: The bulk move request with agent IDs and target folder.\n user_id: ID of the authenticated user.\n\nReturns:\n The updated LibraryAgents.",
|
||||
"operationId": "postV2Bulk move agents",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/BulkMoveAgentsRequest" }
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Agents moved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": { "$ref": "#/components/schemas/LibraryAgent" },
|
||||
"type": "array",
|
||||
"title": "Response Postv2Bulk Move Agents"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"404": { "description": "Folder not found" },
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/library/folders/tree": {
|
||||
"get": {
|
||||
"tags": ["v2", "library", "folders", "private"],
|
||||
"summary": "Get Folder Tree",
|
||||
"description": "Get the full folder tree for the authenticated user.\n\nArgs:\n user_id: ID of the authenticated user.\n\nReturns:\n A FolderTreeResponse containing the nested folder structure.",
|
||||
"operationId": "getV2Get folder tree",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Folder tree structure",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/FolderTreeResponse" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/library/folders/{folder_id}": {
|
||||
"delete": {
|
||||
"tags": ["v2", "library", "folders", "private"],
|
||||
"summary": "Delete Folder",
|
||||
"description": "Soft-delete a folder and all its contents.\n\nArgs:\n folder_id: ID of the folder to delete.\n user_id: ID of the authenticated user.\n\nReturns:\n 204 No Content if successful.",
|
||||
"operationId": "deleteV2Delete folder",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "folder_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "Folder Id" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": { "description": "Folder deleted successfully" },
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"404": { "description": "Folder not found" },
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": ["v2", "library", "folders", "private"],
|
||||
"summary": "Get Folder",
|
||||
"description": "Get a specific folder.\n\nArgs:\n folder_id: ID of the folder to retrieve.\n user_id: ID of the authenticated user.\n\nReturns:\n The requested LibraryFolder.",
|
||||
"operationId": "getV2Get folder",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "folder_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "Folder Id" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Folder details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"404": { "description": "Folder not found" },
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["v2", "library", "folders", "private"],
|
||||
"summary": "Update Folder",
|
||||
"description": "Update a folder's properties.\n\nArgs:\n folder_id: ID of the folder to update.\n payload: The folder update request.\n user_id: ID of the authenticated user.\n\nReturns:\n The updated LibraryFolder.",
|
||||
"operationId": "patchV2Update folder",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "folder_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "Folder Id" }
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/FolderUpdateRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Folder updated successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Validation error" },
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"404": { "description": "Folder not found" },
|
||||
"409": { "description": "Folder name conflict" },
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/folders/{folder_id}/move": {
|
||||
"post": {
|
||||
"tags": ["v2", "library", "folders", "private"],
|
||||
"summary": "Move Folder",
|
||||
"description": "Move a folder to a new parent.\n\nArgs:\n folder_id: ID of the folder to move.\n payload: The move request with target parent.\n user_id: ID of the authenticated user.\n\nReturns:\n The moved LibraryFolder.",
|
||||
"operationId": "postV2Move folder",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "folder_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "Folder Id" }
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/FolderMoveRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Folder moved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error (circular reference, depth exceeded)"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"404": { "description": "Folder or target parent not found" },
|
||||
"409": { "description": "Folder name conflict in target location" },
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/presets": {
|
||||
"get": {
|
||||
"tags": ["v2", "presets"],
|
||||
@@ -7863,23 +7506,6 @@
|
||||
"required": ["file"],
|
||||
"title": "Body_postV2Upload submission media"
|
||||
},
|
||||
"BulkMoveAgentsRequest": {
|
||||
"properties": {
|
||||
"agent_ids": {
|
||||
"items": { "type": "string" },
|
||||
"type": "array",
|
||||
"title": "Agent Ids"
|
||||
},
|
||||
"folder_id": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Folder Id"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["agent_ids"],
|
||||
"title": "BulkMoveAgentsRequest",
|
||||
"description": "Request model for moving multiple agents to a folder."
|
||||
},
|
||||
"ChangelogEntry": {
|
||||
"properties": {
|
||||
"version": { "type": "string", "title": "Version" },
|
||||
@@ -8603,96 +8229,6 @@
|
||||
"title": "ExecutionStartedResponse",
|
||||
"description": "Response for run/schedule actions."
|
||||
},
|
||||
"FolderCreateRequest": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"minLength": 1,
|
||||
"title": "Name"
|
||||
},
|
||||
"icon": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Icon"
|
||||
},
|
||||
"color": {
|
||||
"anyOf": [
|
||||
{ "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Color",
|
||||
"description": "Hex color code (#RRGGBB)"
|
||||
},
|
||||
"parent_id": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Parent Id"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"title": "FolderCreateRequest",
|
||||
"description": "Request model for creating a folder."
|
||||
},
|
||||
"FolderListResponse": {
|
||||
"properties": {
|
||||
"folders": {
|
||||
"items": { "$ref": "#/components/schemas/LibraryFolder" },
|
||||
"type": "array",
|
||||
"title": "Folders"
|
||||
},
|
||||
"pagination": { "$ref": "#/components/schemas/Pagination" }
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["folders", "pagination"],
|
||||
"title": "FolderListResponse",
|
||||
"description": "Response schema for a list of folders."
|
||||
},
|
||||
"FolderMoveRequest": {
|
||||
"properties": {
|
||||
"target_parent_id": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Target Parent Id"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "FolderMoveRequest",
|
||||
"description": "Request model for moving a folder to a new parent."
|
||||
},
|
||||
"FolderTreeResponse": {
|
||||
"properties": {
|
||||
"tree": {
|
||||
"items": { "$ref": "#/components/schemas/LibraryFolderTree" },
|
||||
"type": "array",
|
||||
"title": "Tree"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["tree"],
|
||||
"title": "FolderTreeResponse",
|
||||
"description": "Response schema for folder tree structure."
|
||||
},
|
||||
"FolderUpdateRequest": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{ "type": "string", "maxLength": 100, "minLength": 1 },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Name"
|
||||
},
|
||||
"icon": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Icon"
|
||||
},
|
||||
"color": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Color"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "FolderUpdateRequest",
|
||||
"description": "Request model for updating a folder."
|
||||
},
|
||||
"Graph": {
|
||||
"properties": {
|
||||
"id": { "type": "string", "title": "Id" },
|
||||
@@ -9581,14 +9117,6 @@
|
||||
"title": "Is Latest Version"
|
||||
},
|
||||
"is_favorite": { "type": "boolean", "title": "Is Favorite" },
|
||||
"folder_id": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Folder Id"
|
||||
},
|
||||
"folder_name": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Folder Name"
|
||||
},
|
||||
"recommended_schedule_cron": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Recommended Schedule Cron"
|
||||
@@ -9856,109 +9384,12 @@
|
||||
{ "type": "null" }
|
||||
],
|
||||
"description": "User-specific settings for this library agent"
|
||||
},
|
||||
"folder_id": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Folder Id",
|
||||
"description": "Folder ID to move agent to (empty string for root)"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "LibraryAgentUpdateRequest",
|
||||
"description": "Schema for updating a library agent via PUT.\n\nIncludes flags for auto-updating version, marking as favorite,\narchiving, or deleting."
|
||||
},
|
||||
"LibraryFolder": {
|
||||
"properties": {
|
||||
"id": { "type": "string", "title": "Id" },
|
||||
"user_id": { "type": "string", "title": "User Id" },
|
||||
"name": { "type": "string", "title": "Name" },
|
||||
"icon": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Icon"
|
||||
},
|
||||
"color": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Color"
|
||||
},
|
||||
"parent_id": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Parent Id"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Created At"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Updated At"
|
||||
},
|
||||
"agent_count": {
|
||||
"type": "integer",
|
||||
"title": "Agent Count",
|
||||
"default": 0
|
||||
},
|
||||
"subfolder_count": {
|
||||
"type": "integer",
|
||||
"title": "Subfolder Count",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["id", "user_id", "name", "created_at", "updated_at"],
|
||||
"title": "LibraryFolder",
|
||||
"description": "Represents a folder for organizing library agents."
|
||||
},
|
||||
"LibraryFolderTree": {
|
||||
"properties": {
|
||||
"id": { "type": "string", "title": "Id" },
|
||||
"user_id": { "type": "string", "title": "User Id" },
|
||||
"name": { "type": "string", "title": "Name" },
|
||||
"icon": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Icon"
|
||||
},
|
||||
"color": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Color"
|
||||
},
|
||||
"parent_id": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Parent Id"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Created At"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Updated At"
|
||||
},
|
||||
"agent_count": {
|
||||
"type": "integer",
|
||||
"title": "Agent Count",
|
||||
"default": 0
|
||||
},
|
||||
"subfolder_count": {
|
||||
"type": "integer",
|
||||
"title": "Subfolder Count",
|
||||
"default": 0
|
||||
},
|
||||
"children": {
|
||||
"items": { "$ref": "#/components/schemas/LibraryFolderTree" },
|
||||
"type": "array",
|
||||
"title": "Children",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["id", "user_id", "name", "created_at", "updated_at"],
|
||||
"title": "LibraryFolderTree",
|
||||
"description": "Folder with nested children for tree view."
|
||||
},
|
||||
"Link": {
|
||||
"properties": {
|
||||
"id": { "type": "string", "title": "Id" },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { CronScheduler } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler";
|
||||
import { CronScheduler } from "@/components/contextual/CronScheduler/cron-scheduler";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { getTimezoneDisplayName } from "@/lib/timezone-utils";
|
||||
import { useUserTimezone } from "@/lib/hooks/useUserTimezone";
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CronExpressionDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog";
|
||||
import { CronExpressionDialog } from "@/components/contextual/CronScheduler/cron-scheduler-dialog";
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
export enum Flag {
|
||||
BETA_BLOCKS = "beta-blocks",
|
||||
NEW_BLOCK_MENU = "new-block-menu",
|
||||
NEW_AGENT_RUNS = "new-agent-runs",
|
||||
GRAPH_SEARCH = "graph-search",
|
||||
ENABLE_ENHANCED_OUTPUT_HANDLING = "enable-enhanced-output-handling",
|
||||
SHARE_EXECUTION_RESULTS = "share-execution-results",
|
||||
@@ -22,7 +21,6 @@ const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
const defaultFlags = {
|
||||
[Flag.BETA_BLOCKS]: [],
|
||||
[Flag.NEW_BLOCK_MENU]: false,
|
||||
[Flag.NEW_AGENT_RUNS]: false,
|
||||
[Flag.GRAPH_SEARCH]: false,
|
||||
[Flag.ENABLE_ENHANCED_OUTPUT_HANDLING]: false,
|
||||
[Flag.SHARE_EXECUTION_RESULTS]: false,
|
||||
|
||||
4
autogpt_platform/frontend/src/types/images.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "*.png" {
|
||||
const content: import("next/image").StaticImageData;
|
||||
export default content;
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
## Advanced Setup
|
||||
|
||||
* [Advanced Setup](advanced_setup.md)
|
||||
* [Deployment Environment Variables](deployment-environment-variables.md)
|
||||
|
||||
## Building Blocks
|
||||
|
||||
|
||||
397
docs/platform/deployment-environment-variables.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# Deployment Environment Variables
|
||||
|
||||
This guide documents **all environment variables that must be configured** when deploying AutoGPT to a new server or environment. Use this as a checklist to ensure your deployment works correctly.
|
||||
|
||||
## Quick Reference: What MUST Change
|
||||
|
||||
When deploying to a new server, these variables **must** be updated from their localhost defaults:
|
||||
|
||||
| Variable | Location | Default | Purpose |
|
||||
|----------|----------|---------|---------|
|
||||
| `SITE_URL` | `.env` | `http://localhost:3000` | Frontend URL for auth redirects |
|
||||
| `API_EXTERNAL_URL` | `.env` | `http://localhost:8000` | Public Supabase API URL |
|
||||
| `SUPABASE_PUBLIC_URL` | `.env` | `http://localhost:8000` | Studio dashboard URL |
|
||||
| `PLATFORM_BASE_URL` | `backend/.env` | `http://localhost:8000` | Backend platform URL |
|
||||
| `FRONTEND_BASE_URL` | `backend/.env` | `http://localhost:3000` | Frontend URL for webhooks/OAuth |
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | `frontend/.env` | `http://localhost:8000` | Client-side Supabase URL |
|
||||
| `NEXT_PUBLIC_AGPT_SERVER_URL` | `frontend/.env` | `http://localhost:8006/api` | Client-side backend API URL |
|
||||
| `NEXT_PUBLIC_AGPT_WS_SERVER_URL` | `frontend/.env` | `ws://localhost:8001/ws` | Client-side WebSocket URL |
|
||||
| `NEXT_PUBLIC_FRONTEND_BASE_URL` | `frontend/.env` | `http://localhost:3000` | Client-side frontend URL |
|
||||
|
||||
---
|
||||
|
||||
## Configuration Files
|
||||
|
||||
AutoGPT uses multiple `.env` files across different components:
|
||||
|
||||
```text
|
||||
autogpt_platform/
|
||||
├── .env # Supabase/infrastructure config
|
||||
├── backend/
|
||||
│ ├── .env.default # Backend defaults (DO NOT EDIT)
|
||||
│ └── .env # Your backend overrides
|
||||
└── frontend/
|
||||
├── .env.default # Frontend defaults (DO NOT EDIT)
|
||||
└── .env # Your frontend overrides
|
||||
```
|
||||
|
||||
**Loading Order** (later overrides earlier):
|
||||
|
||||
1. `*.env.default` - Base defaults
|
||||
2. `*.env` - Your overrides
|
||||
3. Docker `environment:` section
|
||||
4. Shell environment variables
|
||||
|
||||
---
|
||||
|
||||
## 1. URL Configuration (REQUIRED)
|
||||
|
||||
These URLs must be updated to match your deployment domain/IP.
|
||||
|
||||
### Root `.env` (Supabase)
|
||||
|
||||
```bash
|
||||
# Auth redirects - where users return after login
|
||||
SITE_URL=https://your-domain.com:3000
|
||||
|
||||
# Public API URL - exposed to clients
|
||||
API_EXTERNAL_URL=https://your-domain.com:8000
|
||||
|
||||
# Studio dashboard URL
|
||||
SUPABASE_PUBLIC_URL=https://your-domain.com:8000
|
||||
```
|
||||
|
||||
### Backend `.env`
|
||||
|
||||
```bash
|
||||
# Platform URLs for webhooks and OAuth callbacks
|
||||
PLATFORM_BASE_URL=https://your-domain.com:8000
|
||||
FRONTEND_BASE_URL=https://your-domain.com:3000
|
||||
|
||||
# Internal Supabase URL (use Docker service name if containerized)
|
||||
SUPABASE_URL=http://kong:8000 # Docker
|
||||
# SUPABASE_URL=https://your-domain.com:8000 # External
|
||||
```
|
||||
|
||||
### Frontend `.env`
|
||||
|
||||
```bash
|
||||
# Client-side URLs (used in browser)
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-domain.com:8000
|
||||
NEXT_PUBLIC_AGPT_SERVER_URL=https://your-domain.com:8006/api
|
||||
NEXT_PUBLIC_AGPT_WS_SERVER_URL=wss://your-domain.com:8001/ws
|
||||
NEXT_PUBLIC_FRONTEND_BASE_URL=https://your-domain.com:3000
|
||||
```
|
||||
|
||||
!!! warning "HTTPS Note"
|
||||
For production, use HTTPS URLs and `wss://` for WebSocket. You'll need a reverse proxy (nginx, Caddy) with SSL certificates.
|
||||
|
||||
!!! info "Port Numbers"
|
||||
The port numbers shown (`:3000`, `:8000`, `:8001`, `:8006`) are internal Docker service ports. In production with a reverse proxy, your public URLs typically won't include port numbers (e.g., `https://your-domain.com` instead of `https://your-domain.com:3000`). Configure your reverse proxy to route external traffic to the internal service ports.
|
||||
|
||||
---
|
||||
|
||||
## 2. Security Keys (MUST REGENERATE)
|
||||
|
||||
These default values are **public** and **must be changed** for production.
|
||||
|
||||
### Root `.env`
|
||||
|
||||
```bash
|
||||
# Database password
|
||||
POSTGRES_PASSWORD=<generate-strong-password>
|
||||
|
||||
# JWT secret for Supabase auth (min 32 chars)
|
||||
JWT_SECRET=<generate-random-string>
|
||||
|
||||
# Supabase keys (regenerate with matching JWT_SECRET)
|
||||
ANON_KEY=<regenerate>
|
||||
SERVICE_ROLE_KEY=<regenerate>
|
||||
|
||||
# Studio dashboard credentials
|
||||
DASHBOARD_USERNAME=<your-username>
|
||||
DASHBOARD_PASSWORD=<strong-password>
|
||||
|
||||
# Encryption keys
|
||||
SECRET_KEY_BASE=<generate-random-string>
|
||||
VAULT_ENC_KEY=<generate-32-char-key> # Run: openssl rand -hex 16
|
||||
```
|
||||
|
||||
### Backend `.env`
|
||||
|
||||
```bash
|
||||
# Must match root POSTGRES_PASSWORD
|
||||
DB_PASS=<same-as-POSTGRES_PASSWORD>
|
||||
|
||||
# Must match root SERVICE_ROLE_KEY
|
||||
SUPABASE_SERVICE_ROLE_KEY=<same-as-SERVICE_ROLE_KEY>
|
||||
|
||||
# Must match root JWT_SECRET
|
||||
JWT_VERIFY_KEY=<same-as-JWT_SECRET>
|
||||
|
||||
# Generate new encryption keys
|
||||
# Run: python -c "from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())"
|
||||
ENCRYPTION_KEY=<generated-fernet-key>
|
||||
UNSUBSCRIBE_SECRET_KEY=<generated-fernet-key>
|
||||
```
|
||||
|
||||
### Generating Keys
|
||||
|
||||
```bash
|
||||
# Generate Fernet encryption key (for ENCRYPTION_KEY, UNSUBSCRIBE_SECRET_KEY)
|
||||
python -c "from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())"
|
||||
|
||||
# Generate random string (for JWT_SECRET, SECRET_KEY_BASE)
|
||||
openssl rand -base64 32
|
||||
|
||||
# Generate 32-character key (for VAULT_ENC_KEY)
|
||||
openssl rand -hex 16
|
||||
|
||||
# Generate Supabase keys (requires matching JWT_SECRET)
|
||||
# Use: https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Configuration
|
||||
|
||||
### Root `.env`
|
||||
|
||||
```bash
|
||||
POSTGRES_HOST=db # Docker service name or external host
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_PASSWORD=<your-password>
|
||||
```
|
||||
|
||||
### Backend `.env`
|
||||
|
||||
```bash
|
||||
DB_USER=postgres
|
||||
DB_PASS=<your-password>
|
||||
DB_NAME=postgres
|
||||
DB_PORT=5432
|
||||
DB_HOST=localhost # Default is localhost; use 'db' in Docker
|
||||
DB_SCHEMA=platform
|
||||
|
||||
# Connection pooling
|
||||
DB_CONNECTION_LIMIT=12
|
||||
DB_CONNECT_TIMEOUT=60
|
||||
DB_POOL_TIMEOUT=300
|
||||
|
||||
# Full connection URL (auto-constructed from above in .env.default)
|
||||
# Variable substitution is handled automatically; only override if you need custom parameters
|
||||
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=${DB_SCHEMA}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Service Dependencies
|
||||
|
||||
### Redis
|
||||
|
||||
```bash
|
||||
REDIS_HOST=redis # Docker: 'redis', External: hostname/IP
|
||||
REDIS_PORT=6379
|
||||
# REDIS_PASSWORD= # Uncomment if using authentication
|
||||
```
|
||||
|
||||
### RabbitMQ
|
||||
|
||||
```bash
|
||||
RABBITMQ_DEFAULT_USER=<username>
|
||||
RABBITMQ_DEFAULT_PASS=<strong-password>
|
||||
# In Docker, host is 'rabbitmq'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Default Ports
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|---------|------|---------|
|
||||
| Frontend | 3000 | Next.js web UI |
|
||||
| Kong (Supabase API) | 8000 | API gateway |
|
||||
| WebSocket Server | 8001 | Real-time updates |
|
||||
| Executor | 8002 | Agent execution |
|
||||
| Scheduler | 8003 | Scheduled tasks |
|
||||
| Database Manager | 8005 | DB operations |
|
||||
| REST Server | 8006 | Main API |
|
||||
| Notification Server | 8007 | Notifications |
|
||||
| PostgreSQL | 5432 | Database |
|
||||
| Redis | 6379 | Cache/queue |
|
||||
| RabbitMQ | 5672/15672 | Message queue |
|
||||
| ClamAV | 3310 | Antivirus scanning |
|
||||
|
||||
---
|
||||
|
||||
## 6. OAuth Callbacks
|
||||
|
||||
When configuring OAuth providers, use this callback URL format:
|
||||
|
||||
```text
|
||||
https://your-domain.com/auth/integrations/oauth_callback
|
||||
# Or with explicit port if not using a reverse proxy:
|
||||
# https://your-domain.com:3000/auth/integrations/oauth_callback
|
||||
```
|
||||
|
||||
### Supported OAuth Providers
|
||||
|
||||
| Provider | Env Variables | Setup URL |
|
||||
|----------|---------------|-----------|
|
||||
| GitHub | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | [github.com/settings/developers](https://github.com/settings/developers) |
|
||||
| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | [console.cloud.google.com](https://console.cloud.google.com/apis/credentials) |
|
||||
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` | [discord.com/developers](https://discord.com/developers/applications) |
|
||||
| Twitter/X | `TWITTER_CLIENT_ID`, `TWITTER_CLIENT_SECRET` | [developer.x.com](https://developer.x.com) |
|
||||
| Notion | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET` | [developers.notion.com](https://developers.notion.com) |
|
||||
| Linear | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET` | [linear.app/settings/api](https://linear.app/settings/api/applications/new) |
|
||||
| Reddit | `REDDIT_CLIENT_ID`, `REDDIT_CLIENT_SECRET` | [reddit.com/prefs/apps](https://reddit.com/prefs/apps) |
|
||||
| Todoist | `TODOIST_CLIENT_ID`, `TODOIST_CLIENT_SECRET` | [developer.todoist.com](https://developer.todoist.com/appconsole.html) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Optional Services
|
||||
|
||||
### AI/LLM Providers
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
GROQ_API_KEY=
|
||||
OPEN_ROUTER_API_KEY=
|
||||
NVIDIA_API_KEY=
|
||||
```
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
```bash
|
||||
# Supabase auth emails
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=<username>
|
||||
SMTP_PASS=<password>
|
||||
SMTP_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# Application emails (Postmark)
|
||||
POSTMARK_SERVER_API_TOKEN=
|
||||
POSTMARK_SENDER_EMAIL=noreply@your-domain.com
|
||||
```
|
||||
|
||||
### Payments (Stripe)
|
||||
|
||||
```bash
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
```
|
||||
|
||||
### Error Tracking (Sentry)
|
||||
|
||||
```bash
|
||||
SENTRY_DSN=
|
||||
```
|
||||
|
||||
### Analytics (PostHog)
|
||||
|
||||
```bash
|
||||
POSTHOG_API_KEY=
|
||||
POSTHOG_HOST=https://eu.i.posthog.com
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment Checklist
|
||||
|
||||
Use this checklist when deploying to a new environment:
|
||||
|
||||
### Pre-deployment
|
||||
|
||||
- [ ] Clone repository and navigate to `autogpt_platform/`
|
||||
- [ ] Copy all `.env.default` files to `.env`
|
||||
- [ ] Determine your deployment domain/IP
|
||||
|
||||
### URL Configuration
|
||||
|
||||
- [ ] Update `SITE_URL` in root `.env`
|
||||
- [ ] Update `API_EXTERNAL_URL` in root `.env`
|
||||
- [ ] Update `SUPABASE_PUBLIC_URL` in root `.env`
|
||||
- [ ] Update `PLATFORM_BASE_URL` in `backend/.env`
|
||||
- [ ] Update `FRONTEND_BASE_URL` in `backend/.env`
|
||||
- [ ] Update all `NEXT_PUBLIC_*` URLs in `frontend/.env`
|
||||
|
||||
### Security
|
||||
|
||||
- [ ] Generate new `POSTGRES_PASSWORD`
|
||||
- [ ] Generate new `JWT_SECRET` (min 32 chars)
|
||||
- [ ] Regenerate `ANON_KEY` and `SERVICE_ROLE_KEY`
|
||||
- [ ] Change `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD`
|
||||
- [ ] Generate new `ENCRYPTION_KEY` (backend)
|
||||
- [ ] Generate new `UNSUBSCRIBE_SECRET_KEY` (backend)
|
||||
- [ ] Update `DB_PASS` to match `POSTGRES_PASSWORD`
|
||||
- [ ] Update `JWT_VERIFY_KEY` to match `JWT_SECRET`
|
||||
- [ ] Update `SUPABASE_SERVICE_ROLE_KEY` to match
|
||||
|
||||
### Services
|
||||
|
||||
- [ ] Configure Redis connection (if external)
|
||||
- [ ] Configure RabbitMQ credentials
|
||||
- [ ] Configure SMTP for emails (if needed)
|
||||
|
||||
### OAuth (if using integrations)
|
||||
|
||||
- [ ] Register OAuth apps with your callback URL
|
||||
- [ ] Add client IDs and secrets to `backend/.env`
|
||||
|
||||
### Post-deployment
|
||||
|
||||
- [ ] Run `docker compose up -d --build`
|
||||
- [ ] Verify frontend loads at your URL
|
||||
- [ ] Test authentication flow
|
||||
- [ ] Test WebSocket connection (real-time updates)
|
||||
|
||||
---
|
||||
|
||||
## 9. Docker vs External Services
|
||||
|
||||
### Running Everything in Docker (Default)
|
||||
|
||||
The docker-compose files automatically set internal hostnames:
|
||||
|
||||
```yaml
|
||||
# Internal Docker service names (container-to-container communication)
|
||||
# These are set automatically in docker-compose.platform.yml
|
||||
DB_HOST: db
|
||||
REDIS_HOST: redis
|
||||
RABBITMQ_HOST: rabbitmq
|
||||
SUPABASE_URL: http://kong:8000
|
||||
```
|
||||
|
||||
### Using External Services
|
||||
|
||||
If using managed services (AWS RDS, Redis Cloud, etc.), override in your `.env`:
|
||||
|
||||
```bash
|
||||
# External PostgreSQL
|
||||
DB_HOST=your-rds-instance.region.rds.amazonaws.com
|
||||
DB_PORT=5432
|
||||
|
||||
# External Redis
|
||||
REDIS_HOST=your-redis.cache.amazonaws.com
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<if-required>
|
||||
|
||||
# External Supabase (hosted)
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Getting Started](getting-started.md) - Basic setup guide
|
||||
- [Advanced Setup](advanced_setup.md) - Development configuration
|
||||
- [OAuth & SSO](integrating/oauth-guide.md) - Integration setup
|
||||