Compare commits

..

1 Commits

Author SHA1 Message Date
Otto
889b4e4152 feat(platform): update OpenAI calls to use responses.create for reasoning models
Adds conditional support for OpenAI's Responses API for reasoning models
(o1, o3, etc.) that are incompatible with chat.completions.create.

Changes:
- Add openai_responses.py helper module with:
  - requires_responses_api() for model detection (exact matching)
  - convert_tools_to_responses_format() for tool format conversion
  - extract_responses_tool_calls() for tool call extraction
  - extract_usage() for normalized token usage
  - extract_responses_content() for content extraction
  - extract_responses_reasoning() for reasoning extraction
- Update llm.py OpenAI provider to conditionally use responses.create
  for reasoning models while keeping chat.completions.create for others
- Add unit tests for helper functions

Resolves: #11624
Linear: OPEN-2911
2026-02-13 08:15:42 +00:00
34 changed files with 521 additions and 4362 deletions

View File

@@ -7,7 +7,6 @@ import prisma.errors
import prisma.models
import prisma.types
from backend.api.features.library.exceptions import FolderValidationError
import backend.api.features.store.exceptions as store_exceptions
import backend.api.features.store.image_gen as store_image_gen
import backend.api.features.store.media as store_media
@@ -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,10 +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)
update_fields["folderId"] = None if folder_id == "" else 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
@@ -929,813 +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,
},
)
for agent in affected_agents:
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}"
)
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 #############
##############################################

View File

@@ -1,4 +0,0 @@
class FolderValidationError(Exception):
"""Raised when folder operations fail validation."""
pass

View File

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

View File

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

View File

@@ -41,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(
@@ -177,38 +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(

View File

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

View File

@@ -32,6 +32,14 @@ from backend.data.model import (
from backend.integrations.providers import ProviderName
from backend.util import json
from backend.util.logging import TruncatedLogger
from backend.util.openai_responses import (
convert_tools_to_responses_format,
extract_responses_content,
extract_responses_reasoning,
extract_responses_tool_calls,
extract_usage,
requires_responses_api,
)
from backend.util.prompt import compress_context, estimate_token_count
from backend.util.text import TextFormatter
@@ -659,38 +667,72 @@ async def llm_call(
max_tokens = max(min(available_tokens, model_max_output, user_max), 1)
if provider == "openai":
tools_param = tools if tools else openai.NOT_GIVEN
oai_client = openai.AsyncOpenAI(api_key=credentials.api_key.get_secret_value())
response_format = None
parallel_tool_calls = get_parallel_tool_calls_param(
llm_model, parallel_tool_calls
)
# Check if this model requires the Responses API (reasoning models: o1, o3, etc.)
if requires_responses_api(llm_model.value):
# Use responses.create for reasoning models
tools_converted = (
convert_tools_to_responses_format(tools) if tools else None
)
if force_json_output:
response_format = {"type": "json_object"}
response = await oai_client.responses.create(
model=llm_model.value,
input=prompt, # type: ignore
tools=tools_converted, # type: ignore
max_output_tokens=max_tokens,
store=False, # Don't persist conversations
)
response = await oai_client.chat.completions.create(
model=llm_model.value,
messages=prompt, # type: ignore
response_format=response_format, # type: ignore
max_completion_tokens=max_tokens,
tools=tools_param, # type: ignore
parallel_tool_calls=parallel_tool_calls,
)
tool_calls = extract_responses_tool_calls(response)
reasoning = extract_responses_reasoning(response)
content = extract_responses_content(response)
prompt_tokens, completion_tokens = extract_usage(response, True)
tool_calls = extract_openai_tool_calls(response)
reasoning = extract_openai_reasoning(response)
return LLMResponse(
raw_response=response,
prompt=prompt,
response=content,
tool_calls=tool_calls,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
reasoning=reasoning,
)
else:
# Use chat.completions.create for standard models
tools_param = tools if tools else openai.NOT_GIVEN
response_format = None
return LLMResponse(
raw_response=response.choices[0].message,
prompt=prompt,
response=response.choices[0].message.content or "",
tool_calls=tool_calls,
prompt_tokens=response.usage.prompt_tokens if response.usage else 0,
completion_tokens=response.usage.completion_tokens if response.usage else 0,
reasoning=reasoning,
)
parallel_tool_calls = get_parallel_tool_calls_param(
llm_model, parallel_tool_calls
)
if force_json_output:
response_format = {"type": "json_object"}
response = await oai_client.chat.completions.create(
model=llm_model.value,
messages=prompt, # type: ignore
response_format=response_format, # type: ignore
max_completion_tokens=max_tokens,
tools=tools_param, # type: ignore
parallel_tool_calls=parallel_tool_calls,
)
tool_calls = extract_openai_tool_calls(response)
reasoning = extract_openai_reasoning(response)
return LLMResponse(
raw_response=response.choices[0].message,
prompt=prompt,
response=response.choices[0].message.content or "",
tool_calls=tool_calls,
prompt_tokens=response.usage.prompt_tokens if response.usage else 0,
completion_tokens=(
response.usage.completion_tokens if response.usage else 0
),
reasoning=reasoning,
)
elif provider == "anthropic":
an_tools = convert_openai_tool_fmt_to_anthropic(tools)

View File

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

View File

@@ -0,0 +1,185 @@
"""Helpers for OpenAI Responses API migration.
This module provides utilities for conditionally using OpenAI's Responses API
instead of Chat Completions for reasoning models (o1, o3, etc.) that require it.
"""
from typing import Any
# Exact model identifiers that require the Responses API.
# Use exact matching to avoid false positives on future models.
# NOTE: Update this set when OpenAI releases new reasoning models.
REASONING_MODELS = frozenset(
{
# O1 family
"o1",
"o1-mini",
"o1-preview",
"o1-2024-12-17",
# O3 family
"o3",
"o3-mini",
"o3-2025-04-16",
"o3-mini-2025-01-31",
}
)
def requires_responses_api(model: str) -> bool:
"""Check if model requires the Responses API (exact match).
Args:
model: The model identifier string (e.g., "o3-mini", "gpt-4o")
Returns:
True if the model requires responses.create, False otherwise
"""
return model in REASONING_MODELS
def convert_tools_to_responses_format(tools: list[dict] | None) -> list[dict]:
"""Convert Chat Completions tool format to Responses API format.
The Responses API uses internally-tagged polymorphism (flatter structure)
and functions are strict by default.
Chat Completions format:
{"type": "function", "function": {"name": "...", "parameters": {...}}}
Responses API format:
{"type": "function", "name": "...", "parameters": {...}}
Args:
tools: List of tools in Chat Completions format
Returns:
List of tools in Responses API format
"""
if not tools:
return []
converted = []
for tool in tools:
if tool.get("type") == "function":
func = tool.get("function", {})
converted.append(
{
"type": "function",
"name": func.get("name"),
"description": func.get("description"),
"parameters": func.get("parameters"),
# Note: strict=True is default in Responses API
}
)
else:
# Pass through non-function tools as-is
converted.append(tool)
return converted
def extract_responses_tool_calls(response: Any) -> list[dict] | None:
"""Extract tool calls from Responses API response.
The Responses API returns tool calls as separate items in the output array
with type="function_call".
Args:
response: The Responses API response object
Returns:
List of tool calls in a normalized format, or None if no tool calls
"""
tool_calls = []
for item in response.output:
if getattr(item, "type", None) == "function_call":
tool_calls.append(
{
"id": item.call_id,
"type": "function",
"function": {
"name": item.name,
"arguments": item.arguments,
},
}
)
return tool_calls if tool_calls else None
def extract_usage(response: Any, is_responses_api: bool) -> tuple[int, int]:
"""Extract token usage from either API response.
The Responses API uses different field names for token counts:
- Chat Completions: prompt_tokens, completion_tokens
- Responses API: input_tokens, output_tokens
Args:
response: The API response object
is_responses_api: True if response is from Responses API
Returns:
Tuple of (prompt_tokens, completion_tokens)
"""
if not response.usage:
return 0, 0
if is_responses_api:
# Responses API uses different field names
return (
getattr(response.usage, "input_tokens", 0),
getattr(response.usage, "output_tokens", 0),
)
else:
# Chat Completions API
return (
getattr(response.usage, "prompt_tokens", 0),
getattr(response.usage, "completion_tokens", 0),
)
def extract_responses_content(response: Any) -> str:
"""Extract text content from Responses API response.
Args:
response: The Responses API response object
Returns:
The text content from the response, or empty string if none
"""
# The SDK provides a helper property
if hasattr(response, "output_text"):
return response.output_text or ""
# Fallback: manually extract from output items
for item in response.output:
if getattr(item, "type", None) == "message":
for content in getattr(item, "content", []):
if getattr(content, "type", None) == "output_text":
return getattr(content, "text", "")
return ""
def extract_responses_reasoning(response: Any) -> str | None:
"""Extract reasoning content from Responses API response.
Reasoning models return their reasoning process in the response,
which can be useful for debugging or display.
Args:
response: The Responses API response object
Returns:
The reasoning text, or None if not present
"""
for item in response.output:
if getattr(item, "type", None) == "reasoning":
# Reasoning items may have summary or content
summary = getattr(item, "summary", [])
if summary:
# Join summary items if present
texts = []
for s in summary:
if hasattr(s, "text"):
texts.append(s.text)
if texts:
return "\n".join(texts)
return None

View File

@@ -0,0 +1,155 @@
"""Tests for OpenAI Responses API helpers."""
import pytest
from backend.util.openai_responses import (
REASONING_MODELS,
convert_tools_to_responses_format,
requires_responses_api,
)
class TestRequiresResponsesApi:
"""Tests for the requires_responses_api function."""
def test_o1_models_require_responses_api(self):
"""O1 family models should require the Responses API."""
assert requires_responses_api("o1") is True
assert requires_responses_api("o1-mini") is True
assert requires_responses_api("o1-preview") is True
assert requires_responses_api("o1-2024-12-17") is True
def test_o3_models_require_responses_api(self):
"""O3 family models should require the Responses API."""
assert requires_responses_api("o3") is True
assert requires_responses_api("o3-mini") is True
assert requires_responses_api("o3-2025-04-16") is True
assert requires_responses_api("o3-mini-2025-01-31") is True
def test_gpt_models_do_not_require_responses_api(self):
"""GPT models should NOT require the Responses API."""
assert requires_responses_api("gpt-4o") is False
assert requires_responses_api("gpt-4o-mini") is False
assert requires_responses_api("gpt-4-turbo") is False
assert requires_responses_api("gpt-3.5-turbo") is False
assert requires_responses_api("gpt-5") is False
assert requires_responses_api("gpt-5-mini") is False
def test_other_models_do_not_require_responses_api(self):
"""Other provider models should NOT require the Responses API."""
assert requires_responses_api("claude-3-opus") is False
assert requires_responses_api("llama-3.3-70b") is False
assert requires_responses_api("gemini-pro") is False
def test_empty_string_does_not_require_responses_api(self):
"""Empty string should not require the Responses API."""
assert requires_responses_api("") is False
def test_exact_matching_no_false_positives(self):
"""Should not match models that just start with 'o1' or 'o3'."""
# These are hypothetical models that start with o1/o3 but aren't
# actually reasoning models
assert requires_responses_api("o1-turbo-hypothetical") is False
assert requires_responses_api("o3-fast-hypothetical") is False
assert requires_responses_api("o100") is False
class TestConvertToolsToResponsesFormat:
"""Tests for the convert_tools_to_responses_format function."""
def test_empty_tools_returns_empty_list(self):
"""Empty or None tools should return empty list."""
assert convert_tools_to_responses_format(None) == []
assert convert_tools_to_responses_format([]) == []
def test_converts_function_tool_format(self):
"""Should convert Chat Completions function format to Responses format."""
chat_completions_tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather in a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
},
"required": ["location"],
},
},
}
]
result = convert_tools_to_responses_format(chat_completions_tools)
assert len(result) == 1
assert result[0]["type"] == "function"
assert result[0]["name"] == "get_weather"
assert result[0]["description"] == "Get the weather in a location"
assert result[0]["parameters"] == {
"type": "object",
"properties": {
"location": {"type": "string"},
},
"required": ["location"],
}
# Should not have nested "function" key
assert "function" not in result[0]
def test_handles_multiple_tools(self):
"""Should handle multiple tools."""
chat_completions_tools = [
{
"type": "function",
"function": {
"name": "tool_1",
"description": "First tool",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "tool_2",
"description": "Second tool",
"parameters": {"type": "object", "properties": {}},
},
},
]
result = convert_tools_to_responses_format(chat_completions_tools)
assert len(result) == 2
assert result[0]["name"] == "tool_1"
assert result[1]["name"] == "tool_2"
def test_passes_through_non_function_tools(self):
"""Non-function tools should be passed through as-is."""
tools = [{"type": "web_search", "config": {"enabled": True}}]
result = convert_tools_to_responses_format(tools)
assert result == tools
class TestReasoningModelsSet:
"""Tests for the REASONING_MODELS constant."""
def test_reasoning_models_is_frozenset(self):
"""REASONING_MODELS should be a frozenset (immutable)."""
assert isinstance(REASONING_MODELS, frozenset)
def test_contains_expected_models(self):
"""Should contain all expected reasoning models."""
expected = {
"o1",
"o1-mini",
"o1-preview",
"o1-2024-12-17",
"o3",
"o3-mini",
"o3-2025-04-16",
"o3-mini-2025-01-31",
}
assert expected.issubset(REASONING_MODELS)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +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,
@@ -29,26 +21,38 @@ export function FavoritesSection({ searchTerm, tabs, activeTab, onTabChange, set
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) => (
@@ -56,7 +60,9 @@ export function FavoritesSection({ searchTerm, tabs, activeTab, onTabChange, set
))}
</div>
</InfiniteScroll>
)}
</>
</div>
{favoriteAgents.length > 0 && <div className="!mt-10 border-t" />}
</div>
);
}

View File

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

View File

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

View File

@@ -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,24 +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,
@@ -40,29 +28,14 @@ export function LibraryAgentCard({
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">
@@ -152,7 +125,6 @@ export function LibraryAgentCard({
)}
</div>
</div>
</motion.div>
</div>
);
}

View File

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

View File

@@ -3,12 +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;
}
@@ -17,46 +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>
);
}

View File

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

View File

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

View File

@@ -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 { keepPreviousData, 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,152 +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);
// --- Favorites ---
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,
};
// --- Folders ---
const { data: rawFoldersData } = useGetV2ListLibraryFolders(undefined, {
query: { select: okData },
});
// When searching, suppress folder data so only agent results show
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,
};
}

View File

@@ -1,381 +0,0 @@
import { useState } from "react";
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>
);
}

View File

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

View File

@@ -1,225 +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 px-1 gap-2"
>
<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 !mb-0"
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>
);
}

View File

@@ -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 &ldquo;{folder.name}&rdquo;? 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>
);
}

View File

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

View File

@@ -1,17 +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 justify-between items-center gap-4">
<LibraryTabs tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} />
<LibraryFolderCreationDialog />
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,53 +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>
);
}

View File

@@ -3588,29 +3588,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": {
@@ -3978,340 +3955,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"],
@@ -7693,23 +7336,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" },
@@ -8394,96 +8020,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" },
@@ -9372,14 +8908,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"
@@ -9647,109 +9175,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" },