mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-14 08:45:12 -05:00
Compare commits
8 Commits
abhi/folde
...
ntindle/go
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7705427bb | ||
|
|
201ec5aa3a | ||
|
|
2b8134a711 | ||
|
|
90b3b5ba16 | ||
|
|
f4f81bc4fc | ||
|
|
c5abc01f25 | ||
|
|
8b7053c1de | ||
|
|
e00c1202ad |
@@ -118,7 +118,7 @@ def build_missing_credentials_from_graph(
|
|||||||
preserving all supported credential types for each field.
|
preserving all supported credential types for each field.
|
||||||
"""
|
"""
|
||||||
matched_keys = set(matched_credentials.keys()) if matched_credentials else set()
|
matched_keys = set(matched_credentials.keys()) if matched_credentials else set()
|
||||||
aggregated_fields = graph.aggregate_credentials_inputs()
|
aggregated_fields = graph.regular_credentials_inputs
|
||||||
|
|
||||||
return {
|
return {
|
||||||
field_key: _serialize_missing_credential(field_key, field_info)
|
field_key: _serialize_missing_credential(field_key, field_info)
|
||||||
@@ -338,7 +338,7 @@ async def match_user_credentials_to_graph(
|
|||||||
missing_creds: list[str] = []
|
missing_creds: list[str] = []
|
||||||
|
|
||||||
# Get aggregated credentials requirements from the graph
|
# Get aggregated credentials requirements from the graph
|
||||||
aggregated_creds = graph.aggregate_credentials_inputs()
|
aggregated_creds = graph.regular_credentials_inputs
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Matching credentials for graph {graph.id}: {len(aggregated_creds)} required"
|
f"Matching credentials for graph {graph.id}: {len(aggregated_creds)} required"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for chat tools utility functions."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.data.model import CredentialsFieldInfo
|
||||||
|
|
||||||
|
|
||||||
|
def _make_regular_field() -> CredentialsFieldInfo:
|
||||||
|
return CredentialsFieldInfo.model_validate(
|
||||||
|
{
|
||||||
|
"credentials_provider": ["github"],
|
||||||
|
"credentials_types": ["api_key"],
|
||||||
|
"is_auto_credential": False,
|
||||||
|
},
|
||||||
|
by_alias=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_missing_credentials_excludes_auto_creds():
|
||||||
|
"""
|
||||||
|
build_missing_credentials_from_graph() should use regular_credentials_inputs
|
||||||
|
and thus exclude auto_credentials from the "missing" set.
|
||||||
|
"""
|
||||||
|
from backend.api.features.chat.tools.utils import (
|
||||||
|
build_missing_credentials_from_graph,
|
||||||
|
)
|
||||||
|
|
||||||
|
regular_field = _make_regular_field()
|
||||||
|
|
||||||
|
mock_graph = MagicMock()
|
||||||
|
# regular_credentials_inputs should only return the non-auto field
|
||||||
|
mock_graph.regular_credentials_inputs = {
|
||||||
|
"github_api_key": (regular_field, {("node-1", "credentials")}, True),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = build_missing_credentials_from_graph(mock_graph, matched_credentials=None)
|
||||||
|
|
||||||
|
# Should include the regular credential
|
||||||
|
assert "github_api_key" in result
|
||||||
|
# Should NOT include the auto_credential (not in regular_credentials_inputs)
|
||||||
|
assert "google_oauth2" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_match_user_credentials_excludes_auto_creds():
|
||||||
|
"""
|
||||||
|
match_user_credentials_to_graph() should use regular_credentials_inputs
|
||||||
|
and thus exclude auto_credentials from matching.
|
||||||
|
"""
|
||||||
|
from backend.api.features.chat.tools.utils import match_user_credentials_to_graph
|
||||||
|
|
||||||
|
regular_field = _make_regular_field()
|
||||||
|
|
||||||
|
mock_graph = MagicMock()
|
||||||
|
mock_graph.id = "test-graph"
|
||||||
|
# regular_credentials_inputs returns only non-auto fields
|
||||||
|
mock_graph.regular_credentials_inputs = {
|
||||||
|
"github_api_key": (regular_field, {("node-1", "credentials")}, True),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock the credentials manager to return no credentials
|
||||||
|
with patch(
|
||||||
|
"backend.api.features.chat.tools.utils.IntegrationCredentialsManager"
|
||||||
|
) as MockCredsMgr:
|
||||||
|
mock_store = AsyncMock()
|
||||||
|
mock_store.get_all_creds.return_value = []
|
||||||
|
MockCredsMgr.return_value.store = mock_store
|
||||||
|
|
||||||
|
matched, missing = await match_user_credentials_to_graph(
|
||||||
|
user_id="test-user", graph=mock_graph
|
||||||
|
)
|
||||||
|
|
||||||
|
# No credentials available, so github should be missing
|
||||||
|
assert len(matched) == 0
|
||||||
|
assert len(missing) == 1
|
||||||
|
assert "github_api_key" in missing[0]
|
||||||
@@ -7,7 +7,6 @@ import prisma.errors
|
|||||||
import prisma.models
|
import prisma.models
|
||||||
import prisma.types
|
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.exceptions as store_exceptions
|
||||||
import backend.api.features.store.image_gen as store_image_gen
|
import backend.api.features.store.image_gen as store_image_gen
|
||||||
import backend.api.features.store.media as store_media
|
import backend.api.features.store.media as store_media
|
||||||
@@ -43,8 +42,6 @@ async def list_library_agents(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
include_executions: bool = False,
|
include_executions: bool = False,
|
||||||
folder_id: Optional[str] = None,
|
|
||||||
include_root_only: bool = False,
|
|
||||||
) -> library_model.LibraryAgentResponse:
|
) -> library_model.LibraryAgentResponse:
|
||||||
"""
|
"""
|
||||||
Retrieves a paginated list of LibraryAgent records for a given user.
|
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).
|
sort_by: Sorting field (createdAt, updatedAt, isFavorite, isCreatedByUser).
|
||||||
page: Current page (1-indexed).
|
page: Current page (1-indexed).
|
||||||
page_size: Number of items per page.
|
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.
|
include_executions: Whether to include execution data for status calculation.
|
||||||
Defaults to False for performance (UI fetches status separately).
|
Defaults to False for performance (UI fetches status separately).
|
||||||
Set to True when accurate status/metrics are needed (e.g., agent generator).
|
Set to True when accurate status/metrics are needed (e.g., agent generator).
|
||||||
@@ -87,13 +82,6 @@ async def list_library_agents(
|
|||||||
"isArchived": False,
|
"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:
|
if search_term:
|
||||||
where_clause["OR"] = [
|
where_clause["OR"] = [
|
||||||
{
|
{
|
||||||
@@ -646,7 +634,6 @@ async def update_library_agent(
|
|||||||
is_archived: Optional[bool] = None,
|
is_archived: Optional[bool] = None,
|
||||||
is_deleted: Optional[Literal[False]] = None,
|
is_deleted: Optional[Literal[False]] = None,
|
||||||
settings: Optional[GraphSettings] = None,
|
settings: Optional[GraphSettings] = None,
|
||||||
folder_id: Optional[str] = None,
|
|
||||||
) -> library_model.LibraryAgent:
|
) -> library_model.LibraryAgent:
|
||||||
"""
|
"""
|
||||||
Updates the specified LibraryAgent record.
|
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_favorite: Whether this agent is marked as a favorite.
|
||||||
is_archived: Whether this agent is archived.
|
is_archived: Whether this agent is archived.
|
||||||
settings: User-specific settings for this library agent.
|
settings: User-specific settings for this library agent.
|
||||||
folder_id: Folder ID to move agent to (empty string "" for root, None to skip).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The updated LibraryAgent.
|
The updated LibraryAgent.
|
||||||
@@ -687,10 +673,13 @@ async def update_library_agent(
|
|||||||
)
|
)
|
||||||
update_fields["isDeleted"] = is_deleted
|
update_fields["isDeleted"] = is_deleted
|
||||||
if settings is not None:
|
if settings is not None:
|
||||||
update_fields["settings"] = SafeJson(settings.model_dump())
|
existing_agent = await get_library_agent(id=library_agent_id, user_id=user_id)
|
||||||
if folder_id is not None:
|
current_settings_dict = (
|
||||||
# Empty string means "move to root" (no folder)
|
existing_agent.settings.model_dump() if existing_agent.settings else {}
|
||||||
update_fields["folderId"] = None if folder_id == "" else folder_id
|
)
|
||||||
|
new_settings = settings.model_dump(exclude_unset=True)
|
||||||
|
merged_settings = {**current_settings_dict, **new_settings}
|
||||||
|
update_fields["settings"] = SafeJson(merged_settings)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# If graph_version is provided, update to that specific version
|
# 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
|
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 #############
|
########### Presets DB Functions #############
|
||||||
##############################################
|
##############################################
|
||||||
@@ -1920,7 +1102,7 @@ async def create_preset_from_graph_execution(
|
|||||||
raise NotFoundError(
|
raise NotFoundError(
|
||||||
f"Graph #{graph_execution.graph_id} not found or accessible"
|
f"Graph #{graph_execution.graph_id} not found or accessible"
|
||||||
)
|
)
|
||||||
elif len(graph.aggregate_credentials_inputs()) > 0:
|
elif len(graph.regular_credentials_inputs) > 0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Graph execution #{graph_exec_id} can't be turned into a preset "
|
f"Graph execution #{graph_exec_id} can't be turned into a preset "
|
||||||
"because it was run before this feature existed "
|
"because it was run before this feature existed "
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
class FolderValidationError(Exception):
|
|
||||||
"""Raised when folder operations fail validation."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
@@ -26,95 +26,6 @@ class LibraryAgentStatus(str, Enum):
|
|||||||
ERROR = "ERROR"
|
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):
|
class MarketplaceListingCreator(pydantic.BaseModel):
|
||||||
"""Creator information for a marketplace listing."""
|
"""Creator information for a marketplace listing."""
|
||||||
|
|
||||||
@@ -209,9 +120,6 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
can_access_graph: bool
|
can_access_graph: bool
|
||||||
is_latest_version: bool
|
is_latest_version: bool
|
||||||
is_favorite: bool
|
is_favorite: bool
|
||||||
folder_id: str | None = None
|
|
||||||
folder_name: str | None = None # Denormalized for display
|
|
||||||
|
|
||||||
recommended_schedule_cron: str | None = None
|
recommended_schedule_cron: str | None = None
|
||||||
settings: GraphSettings = pydantic.Field(default_factory=GraphSettings)
|
settings: GraphSettings = pydantic.Field(default_factory=GraphSettings)
|
||||||
marketplace_listing: Optional["MarketplaceListing"] = None
|
marketplace_listing: Optional["MarketplaceListing"] = None
|
||||||
@@ -320,10 +228,6 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
creator=creator_data,
|
creator=creator_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Folder information
|
|
||||||
folder_id = agent.folderId
|
|
||||||
folder_name = agent.Folder.name if agent.Folder else None
|
|
||||||
|
|
||||||
return LibraryAgent(
|
return LibraryAgent(
|
||||||
id=agent.id,
|
id=agent.id,
|
||||||
graph_id=agent.agentGraphId,
|
graph_id=agent.agentGraphId,
|
||||||
@@ -355,8 +259,6 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
can_access_graph=can_access_graph,
|
can_access_graph=can_access_graph,
|
||||||
is_latest_version=is_latest_version,
|
is_latest_version=is_latest_version,
|
||||||
is_favorite=agent.isFavorite,
|
is_favorite=agent.isFavorite,
|
||||||
folder_id=folder_id,
|
|
||||||
folder_name=folder_name,
|
|
||||||
recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron,
|
recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron,
|
||||||
settings=_parse_settings(agent.settings),
|
settings=_parse_settings(agent.settings),
|
||||||
marketplace_listing=marketplace_listing_data,
|
marketplace_listing=marketplace_listing_data,
|
||||||
@@ -568,7 +470,3 @@ class LibraryAgentUpdateRequest(pydantic.BaseModel):
|
|||||||
settings: Optional[GraphSettings] = pydantic.Field(
|
settings: Optional[GraphSettings] = pydantic.Field(
|
||||||
default=None, description="User-specific settings for this library agent"
|
default=None, description="User-specific settings for this library agent"
|
||||||
)
|
)
|
||||||
folder_id: Optional[str] = pydantic.Field(
|
|
||||||
default=None,
|
|
||||||
description="Folder ID to move agent to (empty string for root)",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import fastapi
|
import fastapi
|
||||||
|
|
||||||
from .agents import router as agents_router
|
from .agents import router as agents_router
|
||||||
from .folders import router as folders_router
|
|
||||||
from .presets import router as presets_router
|
from .presets import router as presets_router
|
||||||
|
|
||||||
router = fastapi.APIRouter()
|
router = fastapi.APIRouter()
|
||||||
|
|
||||||
router.include_router(presets_router)
|
router.include_router(presets_router)
|
||||||
router.include_router(folders_router)
|
|
||||||
router.include_router(agents_router)
|
router.include_router(agents_router)
|
||||||
|
|||||||
@@ -41,34 +41,17 @@ async def list_library_agents(
|
|||||||
ge=1,
|
ge=1,
|
||||||
description="Number of agents per page (must be >= 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:
|
) -> library_model.LibraryAgentResponse:
|
||||||
"""
|
"""
|
||||||
Get all agents in the user's library (both created and saved).
|
Get all agents in the user's library (both created and saved).
|
||||||
"""
|
"""
|
||||||
try:
|
return await library_db.list_library_agents(
|
||||||
return await library_db.list_library_agents(
|
user_id=user_id,
|
||||||
user_id=user_id,
|
search_term=search_term,
|
||||||
search_term=search_term,
|
sort_by=sort_by,
|
||||||
sort_by=sort_by,
|
page=page,
|
||||||
page=page,
|
page_size=page_size,
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -177,38 +160,15 @@ async def update_library_agent(
|
|||||||
"""
|
"""
|
||||||
Update the library agent with the given fields.
|
Update the library agent with the given fields.
|
||||||
"""
|
"""
|
||||||
try:
|
return await library_db.update_library_agent(
|
||||||
return await library_db.update_library_agent(
|
library_agent_id=library_agent_id,
|
||||||
library_agent_id=library_agent_id,
|
user_id=user_id,
|
||||||
user_id=user_id,
|
auto_update_version=payload.auto_update_version,
|
||||||
auto_update_version=payload.auto_update_version,
|
graph_version=payload.graph_version,
|
||||||
graph_version=payload.graph_version,
|
is_favorite=payload.is_favorite,
|
||||||
is_favorite=payload.is_favorite,
|
is_archived=payload.is_archived,
|
||||||
is_archived=payload.is_archived,
|
settings=payload.settings,
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
|
|||||||
@@ -1,408 +0,0 @@
|
|||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import autogpt_libs.auth as autogpt_auth_lib
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Security, status
|
|
||||||
from fastapi.responses import Response
|
|
||||||
|
|
||||||
from backend.util.exceptions import DatabaseError, NotFoundError
|
|
||||||
|
|
||||||
from .. import db as library_db
|
|
||||||
from .. import model as library_model
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/folders",
|
|
||||||
tags=["library", "folders", "private"],
|
|
||||||
dependencies=[Security(autogpt_auth_lib.requires_user)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"",
|
|
||||||
summary="List Library Folders",
|
|
||||||
response_model=library_model.FolderListResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "List of folders"},
|
|
||||||
500: {"description": "Server error"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def list_folders(
|
|
||||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
|
||||||
parent_id: Optional[str] = Query(
|
|
||||||
None,
|
|
||||||
description="Filter by parent folder ID. If not provided, returns root-level folders.",
|
|
||||||
),
|
|
||||||
include_counts: bool = Query(
|
|
||||||
True,
|
|
||||||
description="Include agent and subfolder counts",
|
|
||||||
),
|
|
||||||
) -> library_model.FolderListResponse:
|
|
||||||
"""
|
|
||||||
List folders for the authenticated user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
parent_id: Optional parent folder ID to filter by.
|
|
||||||
include_counts: Whether to include agent and subfolder counts.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A FolderListResponse containing folders.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
folders = await library_db.list_folders(
|
|
||||||
user_id=user_id,
|
|
||||||
parent_id=parent_id,
|
|
||||||
include_counts=include_counts,
|
|
||||||
)
|
|
||||||
return library_model.FolderListResponse(
|
|
||||||
folders=folders,
|
|
||||||
pagination=library_model.Pagination(
|
|
||||||
total_items=len(folders),
|
|
||||||
total_pages=1,
|
|
||||||
current_page=1,
|
|
||||||
page_size=len(folders),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Could not list folders for user #{user_id}: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Internal server error",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/tree",
|
|
||||||
summary="Get Folder Tree",
|
|
||||||
response_model=library_model.FolderTreeResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "Folder tree structure"},
|
|
||||||
500: {"description": "Server error"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_folder_tree(
|
|
||||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
|
||||||
) -> library_model.FolderTreeResponse:
|
|
||||||
"""
|
|
||||||
Get the full folder tree for the authenticated user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A FolderTreeResponse containing the nested folder structure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tree = await library_db.get_folder_tree(user_id=user_id)
|
|
||||||
return library_model.FolderTreeResponse(tree=tree)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Could not get folder tree for user #{user_id}: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Internal server error",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{folder_id}",
|
|
||||||
summary="Get Folder",
|
|
||||||
response_model=library_model.LibraryFolder,
|
|
||||||
responses={
|
|
||||||
200: {"description": "Folder details"},
|
|
||||||
404: {"description": "Folder not found"},
|
|
||||||
500: {"description": "Server error"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_folder(
|
|
||||||
folder_id: str,
|
|
||||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
|
||||||
) -> library_model.LibraryFolder:
|
|
||||||
"""
|
|
||||||
Get a specific folder.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder_id: ID of the folder to retrieve.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The requested LibraryFolder.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await library_db.get_folder(folder_id=folder_id, user_id=user_id)
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Could not get folder #{folder_id}: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Internal server error",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"",
|
|
||||||
summary="Create Folder",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
response_model=library_model.LibraryFolder,
|
|
||||||
responses={
|
|
||||||
201: {"description": "Folder created successfully"},
|
|
||||||
400: {"description": "Validation error"},
|
|
||||||
404: {"description": "Parent folder not found"},
|
|
||||||
409: {"description": "Folder name conflict"},
|
|
||||||
500: {"description": "Server error"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def create_folder(
|
|
||||||
payload: library_model.FolderCreateRequest,
|
|
||||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
|
||||||
) -> library_model.LibraryFolder:
|
|
||||||
"""
|
|
||||||
Create a new folder.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: The folder creation request.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The created LibraryFolder.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await library_db.create_folder(
|
|
||||||
user_id=user_id,
|
|
||||||
name=payload.name,
|
|
||||||
parent_id=payload.parent_id,
|
|
||||||
icon=payload.icon,
|
|
||||||
color=payload.color,
|
|
||||||
)
|
|
||||||
except library_db.FolderValidationError as e:
|
|
||||||
if "already exists" in str(e):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
except DatabaseError as e:
|
|
||||||
logger.error(f"Database error creating folder: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Internal server error",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{folder_id}",
|
|
||||||
summary="Update Folder",
|
|
||||||
response_model=library_model.LibraryFolder,
|
|
||||||
responses={
|
|
||||||
200: {"description": "Folder updated successfully"},
|
|
||||||
400: {"description": "Validation error"},
|
|
||||||
404: {"description": "Folder not found"},
|
|
||||||
409: {"description": "Folder name conflict"},
|
|
||||||
500: {"description": "Server error"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def update_folder(
|
|
||||||
folder_id: str,
|
|
||||||
payload: library_model.FolderUpdateRequest,
|
|
||||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
|
||||||
) -> library_model.LibraryFolder:
|
|
||||||
"""
|
|
||||||
Update a folder's properties.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder_id: ID of the folder to update.
|
|
||||||
payload: The folder update request.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The updated LibraryFolder.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await library_db.update_folder(
|
|
||||||
folder_id=folder_id,
|
|
||||||
user_id=user_id,
|
|
||||||
name=payload.name,
|
|
||||||
icon=payload.icon,
|
|
||||||
color=payload.color,
|
|
||||||
)
|
|
||||||
except library_db.FolderValidationError as e:
|
|
||||||
if "already exists" in str(e):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
except DatabaseError as e:
|
|
||||||
logger.error(f"Database error updating folder #{folder_id}: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Internal server error",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{folder_id}/move",
|
|
||||||
summary="Move Folder",
|
|
||||||
response_model=library_model.LibraryFolder,
|
|
||||||
responses={
|
|
||||||
200: {"description": "Folder moved successfully"},
|
|
||||||
400: {"description": "Validation error (circular reference, depth exceeded)"},
|
|
||||||
404: {"description": "Folder or target parent not found"},
|
|
||||||
409: {"description": "Folder name conflict in target location"},
|
|
||||||
500: {"description": "Server error"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def move_folder(
|
|
||||||
folder_id: str,
|
|
||||||
payload: library_model.FolderMoveRequest,
|
|
||||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
|
||||||
) -> library_model.LibraryFolder:
|
|
||||||
"""
|
|
||||||
Move a folder to a new parent.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder_id: ID of the folder to move.
|
|
||||||
payload: The move request with target parent.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The moved LibraryFolder.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await library_db.move_folder(
|
|
||||||
folder_id=folder_id,
|
|
||||||
user_id=user_id,
|
|
||||||
target_parent_id=payload.target_parent_id,
|
|
||||||
)
|
|
||||||
except library_db.FolderValidationError as e:
|
|
||||||
if "already exists" in str(e):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
except DatabaseError as e:
|
|
||||||
logger.error(f"Database error moving folder #{folder_id}: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Internal server error",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{folder_id}",
|
|
||||||
summary="Delete Folder",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
responses={
|
|
||||||
204: {"description": "Folder deleted successfully"},
|
|
||||||
404: {"description": "Folder not found"},
|
|
||||||
500: {"description": "Server error"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def delete_folder(
|
|
||||||
folder_id: str,
|
|
||||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
|
||||||
) -> Response:
|
|
||||||
"""
|
|
||||||
Soft-delete a folder and all its contents.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder_id: ID of the folder to delete.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
204 No Content if successful.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await library_db.delete_folder(
|
|
||||||
folder_id=folder_id,
|
|
||||||
user_id=user_id,
|
|
||||||
soft_delete=True,
|
|
||||||
)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
except DatabaseError as e:
|
|
||||||
logger.error(f"Database error deleting folder #{folder_id}: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Internal server error",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
# === Bulk Agent Operations ===
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/agents/bulk-move",
|
|
||||||
summary="Bulk Move Agents",
|
|
||||||
response_model=list[library_model.LibraryAgent],
|
|
||||||
responses={
|
|
||||||
200: {"description": "Agents moved successfully"},
|
|
||||||
404: {"description": "Folder not found"},
|
|
||||||
500: {"description": "Server error"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def bulk_move_agents(
|
|
||||||
payload: library_model.BulkMoveAgentsRequest,
|
|
||||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
|
||||||
) -> list[library_model.LibraryAgent]:
|
|
||||||
"""
|
|
||||||
Move multiple agents to a folder.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: The bulk move request with agent IDs and target folder.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The updated LibraryAgents.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await library_db.bulk_move_agents_to_folder(
|
|
||||||
agent_ids=payload.agent_ids,
|
|
||||||
folder_id=payload.folder_id,
|
|
||||||
user_id=user_id,
|
|
||||||
)
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
except DatabaseError as e:
|
|
||||||
logger.error(f"Database error bulk moving agents: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Internal server error",
|
|
||||||
) from e
|
|
||||||
@@ -309,6 +309,8 @@ class BlockSchema(BaseModel):
|
|||||||
"credentials_provider": [config.get("provider", "google")],
|
"credentials_provider": [config.get("provider", "google")],
|
||||||
"credentials_types": [config.get("type", "oauth2")],
|
"credentials_types": [config.get("type", "oauth2")],
|
||||||
"credentials_scopes": config.get("scopes"),
|
"credentials_scopes": config.get("scopes"),
|
||||||
|
"is_auto_credential": True,
|
||||||
|
"input_field_name": info["field_name"],
|
||||||
}
|
}
|
||||||
result[kwarg_name] = CredentialsFieldInfo.model_validate(
|
result[kwarg_name] = CredentialsFieldInfo.model_validate(
|
||||||
auto_schema, by_alias=True
|
auto_schema, by_alias=True
|
||||||
|
|||||||
@@ -434,8 +434,7 @@ class GraphModel(Graph, GraphMeta):
|
|||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def credentials_input_schema(self) -> dict[str, Any]:
|
def credentials_input_schema(self) -> dict[str, Any]:
|
||||||
graph_credentials_inputs = self.aggregate_credentials_inputs()
|
graph_credentials_inputs = self.regular_credentials_inputs
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Combined credentials input fields for graph #{self.id} ({self.name}): "
|
f"Combined credentials input fields for graph #{self.id} ({self.name}): "
|
||||||
f"{graph_credentials_inputs}"
|
f"{graph_credentials_inputs}"
|
||||||
@@ -591,6 +590,28 @@ class GraphModel(Graph, GraphMeta):
|
|||||||
for key, (field_info, node_field_pairs) in combined.items()
|
for key, (field_info, node_field_pairs) in combined.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def regular_credentials_inputs(
|
||||||
|
self,
|
||||||
|
) -> dict[str, tuple[CredentialsFieldInfo, set[tuple[str, str]], bool]]:
|
||||||
|
"""Credentials that need explicit user mapping (CredentialsMetaInput fields)."""
|
||||||
|
return {
|
||||||
|
k: v
|
||||||
|
for k, v in self.aggregate_credentials_inputs().items()
|
||||||
|
if not v[0].is_auto_credential
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auto_credentials_inputs(
|
||||||
|
self,
|
||||||
|
) -> dict[str, tuple[CredentialsFieldInfo, set[tuple[str, str]], bool]]:
|
||||||
|
"""Credentials embedded in file fields (_credentials_id), resolved at execution time."""
|
||||||
|
return {
|
||||||
|
k: v
|
||||||
|
for k, v in self.aggregate_credentials_inputs().items()
|
||||||
|
if v[0].is_auto_credential
|
||||||
|
}
|
||||||
|
|
||||||
def reassign_ids(self, user_id: str, reassign_graph_id: bool = False):
|
def reassign_ids(self, user_id: str, reassign_graph_id: bool = False):
|
||||||
"""
|
"""
|
||||||
Reassigns all IDs in the graph to new UUIDs.
|
Reassigns all IDs in the graph to new UUIDs.
|
||||||
@@ -641,6 +662,16 @@ class GraphModel(Graph, GraphMeta):
|
|||||||
) and graph_id in graph_id_map:
|
) and graph_id in graph_id_map:
|
||||||
node.input_default["graph_id"] = graph_id_map[graph_id]
|
node.input_default["graph_id"] = graph_id_map[graph_id]
|
||||||
|
|
||||||
|
# Clear auto-credentials references (e.g., _credentials_id in
|
||||||
|
# GoogleDriveFile fields) so the new user must re-authenticate
|
||||||
|
# with their own account
|
||||||
|
for node in graph.nodes:
|
||||||
|
if not node.input_default:
|
||||||
|
continue
|
||||||
|
for key, value in node.input_default.items():
|
||||||
|
if isinstance(value, dict) and "_credentials_id" in value:
|
||||||
|
del value["_credentials_id"]
|
||||||
|
|
||||||
def validate_graph(
|
def validate_graph(
|
||||||
self,
|
self,
|
||||||
for_run: bool = False,
|
for_run: bool = False,
|
||||||
|
|||||||
@@ -462,3 +462,329 @@ def test_node_credentials_optional_with_other_metadata():
|
|||||||
assert node.credentials_optional is True
|
assert node.credentials_optional is True
|
||||||
assert node.metadata["position"] == {"x": 100, "y": 200}
|
assert node.metadata["position"] == {"x": 100, "y": 200}
|
||||||
assert node.metadata["customized_name"] == "My Custom Node"
|
assert node.metadata["customized_name"] == "My Custom Node"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for CredentialsFieldInfo.combine() field propagation
|
||||||
|
def test_combine_preserves_is_auto_credential_flag():
|
||||||
|
"""
|
||||||
|
CredentialsFieldInfo.combine() must propagate is_auto_credential and
|
||||||
|
input_field_name to the combined result. Regression test for reviewer
|
||||||
|
finding that combine() dropped these fields.
|
||||||
|
"""
|
||||||
|
from backend.data.model import CredentialsFieldInfo
|
||||||
|
|
||||||
|
auto_field = CredentialsFieldInfo.model_validate(
|
||||||
|
{
|
||||||
|
"credentials_provider": ["google"],
|
||||||
|
"credentials_types": ["oauth2"],
|
||||||
|
"credentials_scopes": ["drive.readonly"],
|
||||||
|
"is_auto_credential": True,
|
||||||
|
"input_field_name": "spreadsheet",
|
||||||
|
},
|
||||||
|
by_alias=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# combine() takes *args of (field_info, key) tuples
|
||||||
|
combined = CredentialsFieldInfo.combine(
|
||||||
|
(auto_field, ("node-1", "credentials")),
|
||||||
|
(auto_field, ("node-2", "credentials")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(combined) == 1
|
||||||
|
group_key = next(iter(combined))
|
||||||
|
combined_info, combined_keys = combined[group_key]
|
||||||
|
|
||||||
|
assert combined_info.is_auto_credential is True
|
||||||
|
assert combined_info.input_field_name == "spreadsheet"
|
||||||
|
assert combined_keys == {("node-1", "credentials"), ("node-2", "credentials")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_combine_preserves_regular_credential_defaults():
|
||||||
|
"""Regular credentials should have is_auto_credential=False after combine()."""
|
||||||
|
from backend.data.model import CredentialsFieldInfo
|
||||||
|
|
||||||
|
regular_field = CredentialsFieldInfo.model_validate(
|
||||||
|
{
|
||||||
|
"credentials_provider": ["github"],
|
||||||
|
"credentials_types": ["api_key"],
|
||||||
|
"is_auto_credential": False,
|
||||||
|
},
|
||||||
|
by_alias=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
combined = CredentialsFieldInfo.combine(
|
||||||
|
(regular_field, ("node-1", "credentials")),
|
||||||
|
)
|
||||||
|
|
||||||
|
group_key = next(iter(combined))
|
||||||
|
combined_info, _ = combined[group_key]
|
||||||
|
|
||||||
|
assert combined_info.is_auto_credential is False
|
||||||
|
assert combined_info.input_field_name is None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for _reassign_ids credential clearing (Fix 3: SECRT-1772)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reassign_ids_clears_credentials_id():
|
||||||
|
"""
|
||||||
|
[SECRT-1772] _reassign_ids should clear _credentials_id from
|
||||||
|
GoogleDriveFile-style input_default fields so forked agents
|
||||||
|
don't retain the original creator's credential references.
|
||||||
|
"""
|
||||||
|
from backend.data.graph import GraphModel
|
||||||
|
|
||||||
|
node = Node(
|
||||||
|
id="node-1",
|
||||||
|
block_id=StoreValueBlock().id,
|
||||||
|
input_default={
|
||||||
|
"spreadsheet": {
|
||||||
|
"_credentials_id": "original-cred-id",
|
||||||
|
"id": "file-123",
|
||||||
|
"name": "test.xlsx",
|
||||||
|
"mimeType": "application/vnd.google-apps.spreadsheet",
|
||||||
|
"url": "https://docs.google.com/spreadsheets/d/file-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
graph = Graph(
|
||||||
|
id="test-graph",
|
||||||
|
name="Test",
|
||||||
|
description="Test",
|
||||||
|
nodes=[node],
|
||||||
|
links=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
GraphModel._reassign_ids(graph, user_id="new-user", graph_id_map={})
|
||||||
|
|
||||||
|
# _credentials_id key should be removed (not set to None) so that
|
||||||
|
# _acquire_auto_credentials correctly errors instead of treating it as chained data
|
||||||
|
assert "_credentials_id" not in graph.nodes[0].input_default["spreadsheet"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reassign_ids_preserves_non_credential_fields():
|
||||||
|
"""
|
||||||
|
Regression guard: _reassign_ids should NOT modify non-credential fields
|
||||||
|
like name, mimeType, id, url.
|
||||||
|
"""
|
||||||
|
from backend.data.graph import GraphModel
|
||||||
|
|
||||||
|
node = Node(
|
||||||
|
id="node-1",
|
||||||
|
block_id=StoreValueBlock().id,
|
||||||
|
input_default={
|
||||||
|
"spreadsheet": {
|
||||||
|
"_credentials_id": "cred-abc",
|
||||||
|
"id": "file-123",
|
||||||
|
"name": "test.xlsx",
|
||||||
|
"mimeType": "application/vnd.google-apps.spreadsheet",
|
||||||
|
"url": "https://docs.google.com/spreadsheets/d/file-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
graph = Graph(
|
||||||
|
id="test-graph",
|
||||||
|
name="Test",
|
||||||
|
description="Test",
|
||||||
|
nodes=[node],
|
||||||
|
links=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
GraphModel._reassign_ids(graph, user_id="new-user", graph_id_map={})
|
||||||
|
|
||||||
|
field = graph.nodes[0].input_default["spreadsheet"]
|
||||||
|
assert field["id"] == "file-123"
|
||||||
|
assert field["name"] == "test.xlsx"
|
||||||
|
assert field["mimeType"] == "application/vnd.google-apps.spreadsheet"
|
||||||
|
assert field["url"] == "https://docs.google.com/spreadsheets/d/file-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reassign_ids_handles_no_credentials():
|
||||||
|
"""
|
||||||
|
Regression guard: _reassign_ids should not error when input_default
|
||||||
|
has no dict fields with _credentials_id.
|
||||||
|
"""
|
||||||
|
from backend.data.graph import GraphModel
|
||||||
|
|
||||||
|
node = Node(
|
||||||
|
id="node-1",
|
||||||
|
block_id=StoreValueBlock().id,
|
||||||
|
input_default={
|
||||||
|
"input": "some value",
|
||||||
|
"another_input": 42,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
graph = Graph(
|
||||||
|
id="test-graph",
|
||||||
|
name="Test",
|
||||||
|
description="Test",
|
||||||
|
nodes=[node],
|
||||||
|
links=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
GraphModel._reassign_ids(graph, user_id="new-user", graph_id_map={})
|
||||||
|
|
||||||
|
# Should not error, fields unchanged
|
||||||
|
assert graph.nodes[0].input_default["input"] == "some value"
|
||||||
|
assert graph.nodes[0].input_default["another_input"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_reassign_ids_handles_multiple_credential_fields():
|
||||||
|
"""
|
||||||
|
[SECRT-1772] When a node has multiple dict fields with _credentials_id,
|
||||||
|
ALL of them should be cleared.
|
||||||
|
"""
|
||||||
|
from backend.data.graph import GraphModel
|
||||||
|
|
||||||
|
node = Node(
|
||||||
|
id="node-1",
|
||||||
|
block_id=StoreValueBlock().id,
|
||||||
|
input_default={
|
||||||
|
"spreadsheet": {
|
||||||
|
"_credentials_id": "cred-1",
|
||||||
|
"id": "file-1",
|
||||||
|
"name": "file1.xlsx",
|
||||||
|
},
|
||||||
|
"doc_file": {
|
||||||
|
"_credentials_id": "cred-2",
|
||||||
|
"id": "file-2",
|
||||||
|
"name": "file2.docx",
|
||||||
|
},
|
||||||
|
"plain_input": "not a dict",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
graph = Graph(
|
||||||
|
id="test-graph",
|
||||||
|
name="Test",
|
||||||
|
description="Test",
|
||||||
|
nodes=[node],
|
||||||
|
links=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
GraphModel._reassign_ids(graph, user_id="new-user", graph_id_map={})
|
||||||
|
|
||||||
|
assert "_credentials_id" not in graph.nodes[0].input_default["spreadsheet"]
|
||||||
|
assert "_credentials_id" not in graph.nodes[0].input_default["doc_file"]
|
||||||
|
assert graph.nodes[0].input_default["plain_input"] == "not a dict"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for discriminate() field propagation
|
||||||
|
def test_discriminate_preserves_is_auto_credential_flag():
|
||||||
|
"""
|
||||||
|
CredentialsFieldInfo.discriminate() must propagate is_auto_credential and
|
||||||
|
input_field_name to the discriminated result. Regression test for
|
||||||
|
discriminate() dropping these fields (same class of bug as combine()).
|
||||||
|
"""
|
||||||
|
from backend.data.model import CredentialsFieldInfo
|
||||||
|
|
||||||
|
auto_field = CredentialsFieldInfo.model_validate(
|
||||||
|
{
|
||||||
|
"credentials_provider": ["google", "openai"],
|
||||||
|
"credentials_types": ["oauth2"],
|
||||||
|
"credentials_scopes": ["drive.readonly"],
|
||||||
|
"is_auto_credential": True,
|
||||||
|
"input_field_name": "spreadsheet",
|
||||||
|
"discriminator": "model",
|
||||||
|
"discriminator_mapping": {"gpt-4": "openai", "gemini": "google"},
|
||||||
|
},
|
||||||
|
by_alias=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
discriminated = auto_field.discriminate("gemini")
|
||||||
|
|
||||||
|
assert discriminated.is_auto_credential is True
|
||||||
|
assert discriminated.input_field_name == "spreadsheet"
|
||||||
|
assert discriminated.provider == frozenset(["google"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_discriminate_preserves_regular_credential_defaults():
|
||||||
|
"""Regular credentials should have is_auto_credential=False after discriminate()."""
|
||||||
|
from backend.data.model import CredentialsFieldInfo
|
||||||
|
|
||||||
|
regular_field = CredentialsFieldInfo.model_validate(
|
||||||
|
{
|
||||||
|
"credentials_provider": ["google", "openai"],
|
||||||
|
"credentials_types": ["api_key"],
|
||||||
|
"is_auto_credential": False,
|
||||||
|
"discriminator": "model",
|
||||||
|
"discriminator_mapping": {"gpt-4": "openai", "gemini": "google"},
|
||||||
|
},
|
||||||
|
by_alias=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
discriminated = regular_field.discriminate("gpt-4")
|
||||||
|
|
||||||
|
assert discriminated.is_auto_credential is False
|
||||||
|
assert discriminated.input_field_name is None
|
||||||
|
assert discriminated.provider == frozenset(["openai"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for credentials_input_schema excluding auto_credentials
|
||||||
|
def test_credentials_input_schema_excludes_auto_creds():
|
||||||
|
"""
|
||||||
|
GraphModel.credentials_input_schema should exclude auto_credentials
|
||||||
|
(is_auto_credential=True) from the schema. Auto_credentials are
|
||||||
|
transparently resolved at execution time via file picker data.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
from backend.data.graph import GraphModel, NodeModel
|
||||||
|
from backend.data.model import CredentialsFieldInfo
|
||||||
|
|
||||||
|
regular_field_info = CredentialsFieldInfo.model_validate(
|
||||||
|
{
|
||||||
|
"credentials_provider": ["github"],
|
||||||
|
"credentials_types": ["api_key"],
|
||||||
|
"is_auto_credential": False,
|
||||||
|
},
|
||||||
|
by_alias=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
graph = GraphModel(
|
||||||
|
id="test-graph",
|
||||||
|
version=1,
|
||||||
|
name="Test",
|
||||||
|
description="Test",
|
||||||
|
user_id="test-user",
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
nodes=[
|
||||||
|
NodeModel(
|
||||||
|
id="node-1",
|
||||||
|
block_id=StoreValueBlock().id,
|
||||||
|
input_default={},
|
||||||
|
graph_id="test-graph",
|
||||||
|
graph_version=1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
links=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock regular_credentials_inputs to return only the non-auto field (3-tuple)
|
||||||
|
regular_only = {
|
||||||
|
"github_credentials": (
|
||||||
|
regular_field_info,
|
||||||
|
{("node-1", "credentials")},
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
type(graph),
|
||||||
|
"regular_credentials_inputs",
|
||||||
|
new_callable=PropertyMock,
|
||||||
|
return_value=regular_only,
|
||||||
|
):
|
||||||
|
schema = graph.credentials_input_schema
|
||||||
|
field_names = set(schema.get("properties", {}).keys())
|
||||||
|
# Should include regular credential but NOT auto_credential
|
||||||
|
assert "github_credentials" in field_names
|
||||||
|
assert "google_credentials" not in field_names
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ def library_agent_include(
|
|||||||
"""
|
"""
|
||||||
result: prisma.types.LibraryAgentInclude = {
|
result: prisma.types.LibraryAgentInclude = {
|
||||||
"Creator": True, # Always needed for creator info
|
"Creator": True, # Always needed for creator info
|
||||||
"Folder": True, # Always needed for folder info
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build AgentGraph include based on requested options
|
# Build AgentGraph include based on requested options
|
||||||
|
|||||||
@@ -574,6 +574,8 @@ class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
|||||||
discriminator: Optional[str] = None
|
discriminator: Optional[str] = None
|
||||||
discriminator_mapping: Optional[dict[str, CP]] = None
|
discriminator_mapping: Optional[dict[str, CP]] = None
|
||||||
discriminator_values: set[Any] = Field(default_factory=set)
|
discriminator_values: set[Any] = Field(default_factory=set)
|
||||||
|
is_auto_credential: bool = False
|
||||||
|
input_field_name: Optional[str] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def combine(
|
def combine(
|
||||||
@@ -654,6 +656,9 @@ class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
|||||||
+ "_credentials"
|
+ "_credentials"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Propagate is_auto_credential from the combined field.
|
||||||
|
# All fields in a group should share the same is_auto_credential
|
||||||
|
# value since auto and regular credentials serve different purposes.
|
||||||
result[group_key] = (
|
result[group_key] = (
|
||||||
CredentialsFieldInfo[CP, CT](
|
CredentialsFieldInfo[CP, CT](
|
||||||
credentials_provider=combined.provider,
|
credentials_provider=combined.provider,
|
||||||
@@ -662,6 +667,8 @@ class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
|||||||
discriminator=combined.discriminator,
|
discriminator=combined.discriminator,
|
||||||
discriminator_mapping=combined.discriminator_mapping,
|
discriminator_mapping=combined.discriminator_mapping,
|
||||||
discriminator_values=set(all_discriminator_values),
|
discriminator_values=set(all_discriminator_values),
|
||||||
|
is_auto_credential=combined.is_auto_credential,
|
||||||
|
input_field_name=combined.input_field_name,
|
||||||
),
|
),
|
||||||
combined_keys,
|
combined_keys,
|
||||||
)
|
)
|
||||||
@@ -687,6 +694,8 @@ class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
|||||||
discriminator=self.discriminator,
|
discriminator=self.discriminator,
|
||||||
discriminator_mapping=self.discriminator_mapping,
|
discriminator_mapping=self.discriminator_mapping,
|
||||||
discriminator_values=self.discriminator_values,
|
discriminator_values=self.discriminator_values,
|
||||||
|
is_auto_credential=self.is_auto_credential,
|
||||||
|
input_field_name=self.input_field_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,81 @@ def execute_graph(
|
|||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
async def _acquire_auto_credentials(
|
||||||
|
input_model: type[BlockSchema],
|
||||||
|
input_data: dict[str, Any],
|
||||||
|
creds_manager: "IntegrationCredentialsManager",
|
||||||
|
user_id: str,
|
||||||
|
) -> tuple[dict[str, Any], list[AsyncRedisLock]]:
|
||||||
|
"""
|
||||||
|
Resolve auto_credentials from GoogleDriveFileField-style inputs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(extra_exec_kwargs, locks): kwargs to inject into block execution, and
|
||||||
|
credential locks to release after execution completes.
|
||||||
|
"""
|
||||||
|
extra_exec_kwargs: dict[str, Any] = {}
|
||||||
|
locks: list[AsyncRedisLock] = []
|
||||||
|
|
||||||
|
# NOTE: If a block ever has multiple auto-credential fields, a ValueError
|
||||||
|
# on a later field will strand locks acquired for earlier fields. They'll
|
||||||
|
# auto-expire via Redis TTL, but add a try/except to release partial locks
|
||||||
|
# if that becomes a real scenario.
|
||||||
|
for kwarg_name, info in input_model.get_auto_credentials_fields().items():
|
||||||
|
field_name = info["field_name"]
|
||||||
|
field_data = input_data.get(field_name)
|
||||||
|
|
||||||
|
if field_data and isinstance(field_data, dict):
|
||||||
|
# Check if _credentials_id key exists in the field data
|
||||||
|
if "_credentials_id" in field_data:
|
||||||
|
cred_id = field_data["_credentials_id"]
|
||||||
|
if cred_id:
|
||||||
|
# Credential ID provided - acquire credentials
|
||||||
|
provider = info.get("config", {}).get(
|
||||||
|
"provider", "external service"
|
||||||
|
)
|
||||||
|
file_name = field_data.get("name", "selected file")
|
||||||
|
try:
|
||||||
|
credentials, lock = await creds_manager.acquire(
|
||||||
|
user_id, cred_id
|
||||||
|
)
|
||||||
|
locks.append(lock)
|
||||||
|
extra_exec_kwargs[kwarg_name] = credentials
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"{provider.capitalize()} credentials for "
|
||||||
|
f"'{file_name}' in field '{field_name}' are not "
|
||||||
|
f"available in your account. "
|
||||||
|
f"This can happen if the agent was created by another "
|
||||||
|
f"user or the credentials were deleted. "
|
||||||
|
f"Please open the agent in the builder and re-select "
|
||||||
|
f"the file to authenticate with your own account."
|
||||||
|
)
|
||||||
|
# else: _credentials_id is explicitly None, skip (chained data)
|
||||||
|
else:
|
||||||
|
# _credentials_id key missing entirely - this is an error
|
||||||
|
provider = info.get("config", {}).get("provider", "external service")
|
||||||
|
file_name = field_data.get("name", "selected file")
|
||||||
|
raise ValueError(
|
||||||
|
f"Authentication missing for '{file_name}' in field "
|
||||||
|
f"'{field_name}'. Please re-select the file to authenticate "
|
||||||
|
f"with {provider.capitalize()}."
|
||||||
|
)
|
||||||
|
elif field_data is None and field_name not in input_data:
|
||||||
|
# Field not in input_data at all = connected from upstream block, skip
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# field_data is None/empty but key IS in input_data = user didn't select
|
||||||
|
provider = info.get("config", {}).get("provider", "external service")
|
||||||
|
raise ValueError(
|
||||||
|
f"No file selected for '{field_name}'. "
|
||||||
|
f"Please select a file to provide "
|
||||||
|
f"{provider.capitalize()} authentication."
|
||||||
|
)
|
||||||
|
|
||||||
|
return extra_exec_kwargs, locks
|
||||||
|
|
||||||
|
|
||||||
async def execute_node(
|
async def execute_node(
|
||||||
node: Node,
|
node: Node,
|
||||||
data: NodeExecutionEntry,
|
data: NodeExecutionEntry,
|
||||||
@@ -270,41 +345,14 @@ async def execute_node(
|
|||||||
extra_exec_kwargs[field_name] = credentials
|
extra_exec_kwargs[field_name] = credentials
|
||||||
|
|
||||||
# Handle auto-generated credentials (e.g., from GoogleDriveFileInput)
|
# Handle auto-generated credentials (e.g., from GoogleDriveFileInput)
|
||||||
for kwarg_name, info in input_model.get_auto_credentials_fields().items():
|
auto_extra_kwargs, auto_locks = await _acquire_auto_credentials(
|
||||||
field_name = info["field_name"]
|
input_model=input_model,
|
||||||
field_data = input_data.get(field_name)
|
input_data=input_data,
|
||||||
if field_data and isinstance(field_data, dict):
|
creds_manager=creds_manager,
|
||||||
# Check if _credentials_id key exists in the field data
|
user_id=user_id,
|
||||||
if "_credentials_id" in field_data:
|
)
|
||||||
cred_id = field_data["_credentials_id"]
|
extra_exec_kwargs.update(auto_extra_kwargs)
|
||||||
if cred_id:
|
creds_locks.extend(auto_locks)
|
||||||
# Credential ID provided - acquire credentials
|
|
||||||
provider = info.get("config", {}).get(
|
|
||||||
"provider", "external service"
|
|
||||||
)
|
|
||||||
file_name = field_data.get("name", "selected file")
|
|
||||||
try:
|
|
||||||
credentials, lock = await creds_manager.acquire(
|
|
||||||
user_id, cred_id
|
|
||||||
)
|
|
||||||
creds_locks.append(lock)
|
|
||||||
extra_exec_kwargs[kwarg_name] = credentials
|
|
||||||
except ValueError:
|
|
||||||
# Credential was deleted or doesn't exist
|
|
||||||
raise ValueError(
|
|
||||||
f"Authentication expired for '{file_name}' in field '{field_name}'. "
|
|
||||||
f"The saved {provider.capitalize()} credentials no longer exist. "
|
|
||||||
f"Please re-select the file to re-authenticate."
|
|
||||||
)
|
|
||||||
# else: _credentials_id is explicitly None, skip credentials (for chained data)
|
|
||||||
else:
|
|
||||||
# _credentials_id key missing entirely - this is an error
|
|
||||||
provider = info.get("config", {}).get("provider", "external service")
|
|
||||||
file_name = field_data.get("name", "selected file")
|
|
||||||
raise ValueError(
|
|
||||||
f"Authentication missing for '{file_name}' in field '{field_name}'. "
|
|
||||||
f"Please re-select the file to authenticate with {provider.capitalize()}."
|
|
||||||
)
|
|
||||||
|
|
||||||
output_size = 0
|
output_size = 0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""
|
||||||
|
Tests for auto_credentials handling in execute_node().
|
||||||
|
|
||||||
|
These test the _acquire_auto_credentials() helper function extracted from
|
||||||
|
execute_node() (manager.py lines 273-308).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def google_drive_file_data():
|
||||||
|
return {
|
||||||
|
"valid": {
|
||||||
|
"_credentials_id": "cred-id-123",
|
||||||
|
"id": "file-123",
|
||||||
|
"name": "test.xlsx",
|
||||||
|
"mimeType": "application/vnd.google-apps.spreadsheet",
|
||||||
|
},
|
||||||
|
"chained": {
|
||||||
|
"_credentials_id": None,
|
||||||
|
"id": "file-456",
|
||||||
|
"name": "chained.xlsx",
|
||||||
|
"mimeType": "application/vnd.google-apps.spreadsheet",
|
||||||
|
},
|
||||||
|
"missing_key": {
|
||||||
|
"id": "file-789",
|
||||||
|
"name": "bad.xlsx",
|
||||||
|
"mimeType": "application/vnd.google-apps.spreadsheet",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_input_model(mocker: MockerFixture):
|
||||||
|
"""Create a mock input model with get_auto_credentials_fields() returning one field."""
|
||||||
|
input_model = mocker.MagicMock()
|
||||||
|
input_model.get_auto_credentials_fields.return_value = {
|
||||||
|
"credentials": {
|
||||||
|
"field_name": "spreadsheet",
|
||||||
|
"config": {
|
||||||
|
"provider": "google",
|
||||||
|
"type": "oauth2",
|
||||||
|
"scopes": ["https://www.googleapis.com/auth/drive.readonly"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input_model
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_creds_manager(mocker: MockerFixture):
|
||||||
|
manager = mocker.AsyncMock()
|
||||||
|
mock_lock = mocker.AsyncMock()
|
||||||
|
mock_creds = mocker.MagicMock()
|
||||||
|
mock_creds.id = "cred-id-123"
|
||||||
|
mock_creds.provider = "google"
|
||||||
|
manager.acquire.return_value = (mock_creds, mock_lock)
|
||||||
|
return manager, mock_creds, mock_lock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_credentials_happy_path(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
google_drive_file_data,
|
||||||
|
mock_input_model,
|
||||||
|
mock_creds_manager,
|
||||||
|
):
|
||||||
|
"""When field_data has a valid _credentials_id, credentials should be acquired."""
|
||||||
|
from backend.executor.manager import _acquire_auto_credentials
|
||||||
|
|
||||||
|
manager, mock_creds, mock_lock = mock_creds_manager
|
||||||
|
input_data = {"spreadsheet": google_drive_file_data["valid"]}
|
||||||
|
|
||||||
|
extra_kwargs, locks = await _acquire_auto_credentials(
|
||||||
|
input_model=mock_input_model,
|
||||||
|
input_data=input_data,
|
||||||
|
creds_manager=manager,
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.acquire.assert_called_once_with("user-1", "cred-id-123")
|
||||||
|
assert extra_kwargs["credentials"] == mock_creds
|
||||||
|
assert mock_lock in locks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_credentials_field_none_static_raises(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
mock_input_model,
|
||||||
|
mock_creds_manager,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
[THE BUG FIX TEST — OPEN-2895]
|
||||||
|
When field_data is None and the key IS in input_data (user didn't select a file),
|
||||||
|
should raise ValueError instead of silently skipping.
|
||||||
|
"""
|
||||||
|
from backend.executor.manager import _acquire_auto_credentials
|
||||||
|
|
||||||
|
manager, _, _ = mock_creds_manager
|
||||||
|
# Key is present but value is None = user didn't select a file
|
||||||
|
input_data = {"spreadsheet": None}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No file selected"):
|
||||||
|
await _acquire_auto_credentials(
|
||||||
|
input_model=mock_input_model,
|
||||||
|
input_data=input_data,
|
||||||
|
creds_manager=manager,
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_credentials_field_absent_skips(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
mock_input_model,
|
||||||
|
mock_creds_manager,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the field key is NOT in input_data at all (upstream connection),
|
||||||
|
should skip without error.
|
||||||
|
"""
|
||||||
|
from backend.executor.manager import _acquire_auto_credentials
|
||||||
|
|
||||||
|
manager, _, _ = mock_creds_manager
|
||||||
|
# Key not present = connected from upstream block
|
||||||
|
input_data = {}
|
||||||
|
|
||||||
|
extra_kwargs, locks = await _acquire_auto_credentials(
|
||||||
|
input_model=mock_input_model,
|
||||||
|
input_data=input_data,
|
||||||
|
creds_manager=manager,
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.acquire.assert_not_called()
|
||||||
|
assert "credentials" not in extra_kwargs
|
||||||
|
assert locks == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_credentials_chained_cred_id_none(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
google_drive_file_data,
|
||||||
|
mock_input_model,
|
||||||
|
mock_creds_manager,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When _credentials_id is explicitly None (chained data from upstream),
|
||||||
|
should skip credential acquisition.
|
||||||
|
"""
|
||||||
|
from backend.executor.manager import _acquire_auto_credentials
|
||||||
|
|
||||||
|
manager, _, _ = mock_creds_manager
|
||||||
|
input_data = {"spreadsheet": google_drive_file_data["chained"]}
|
||||||
|
|
||||||
|
extra_kwargs, locks = await _acquire_auto_credentials(
|
||||||
|
input_model=mock_input_model,
|
||||||
|
input_data=input_data,
|
||||||
|
creds_manager=manager,
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.acquire.assert_not_called()
|
||||||
|
assert "credentials" not in extra_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_credentials_missing_cred_id_key_raises(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
google_drive_file_data,
|
||||||
|
mock_input_model,
|
||||||
|
mock_creds_manager,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When _credentials_id key is missing entirely from field_data dict,
|
||||||
|
should raise ValueError.
|
||||||
|
"""
|
||||||
|
from backend.executor.manager import _acquire_auto_credentials
|
||||||
|
|
||||||
|
manager, _, _ = mock_creds_manager
|
||||||
|
input_data = {"spreadsheet": google_drive_file_data["missing_key"]}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Authentication missing"):
|
||||||
|
await _acquire_auto_credentials(
|
||||||
|
input_model=mock_input_model,
|
||||||
|
input_data=input_data,
|
||||||
|
creds_manager=manager,
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_credentials_ownership_mismatch_error(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
google_drive_file_data,
|
||||||
|
mock_input_model,
|
||||||
|
mock_creds_manager,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
[SECRT-1772] When acquire() raises ValueError (credential belongs to another user),
|
||||||
|
the error message should mention 'not available' (not 'expired').
|
||||||
|
"""
|
||||||
|
from backend.executor.manager import _acquire_auto_credentials
|
||||||
|
|
||||||
|
manager, _, _ = mock_creds_manager
|
||||||
|
manager.acquire.side_effect = ValueError(
|
||||||
|
"Credentials #cred-id-123 for user #user-2 not found"
|
||||||
|
)
|
||||||
|
input_data = {"spreadsheet": google_drive_file_data["valid"]}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not available in your account"):
|
||||||
|
await _acquire_auto_credentials(
|
||||||
|
input_model=mock_input_model,
|
||||||
|
input_data=input_data,
|
||||||
|
creds_manager=manager,
|
||||||
|
user_id="user-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_credentials_deleted_credential_error(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
google_drive_file_data,
|
||||||
|
mock_input_model,
|
||||||
|
mock_creds_manager,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
[SECRT-1772] When acquire() raises ValueError (credential was deleted),
|
||||||
|
the error message should mention 'not available' (not 'expired').
|
||||||
|
"""
|
||||||
|
from backend.executor.manager import _acquire_auto_credentials
|
||||||
|
|
||||||
|
manager, _, _ = mock_creds_manager
|
||||||
|
manager.acquire.side_effect = ValueError(
|
||||||
|
"Credentials #cred-id-123 for user #user-1 not found"
|
||||||
|
)
|
||||||
|
input_data = {"spreadsheet": google_drive_file_data["valid"]}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not available in your account"):
|
||||||
|
await _acquire_auto_credentials(
|
||||||
|
input_model=mock_input_model,
|
||||||
|
input_data=input_data,
|
||||||
|
creds_manager=manager,
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_credentials_lock_appended(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
google_drive_file_data,
|
||||||
|
mock_input_model,
|
||||||
|
mock_creds_manager,
|
||||||
|
):
|
||||||
|
"""Lock from acquire() should be included in returned locks list."""
|
||||||
|
from backend.executor.manager import _acquire_auto_credentials
|
||||||
|
|
||||||
|
manager, _, mock_lock = mock_creds_manager
|
||||||
|
input_data = {"spreadsheet": google_drive_file_data["valid"]}
|
||||||
|
|
||||||
|
extra_kwargs, locks = await _acquire_auto_credentials(
|
||||||
|
input_model=mock_input_model,
|
||||||
|
input_data=input_data,
|
||||||
|
creds_manager=manager,
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(locks) == 1
|
||||||
|
assert locks[0] is mock_lock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_credentials_multiple_fields(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
mock_creds_manager,
|
||||||
|
):
|
||||||
|
"""When there are multiple auto_credentials fields, only valid ones should acquire."""
|
||||||
|
from backend.executor.manager import _acquire_auto_credentials
|
||||||
|
|
||||||
|
manager, mock_creds, mock_lock = mock_creds_manager
|
||||||
|
|
||||||
|
input_model = mocker.MagicMock()
|
||||||
|
input_model.get_auto_credentials_fields.return_value = {
|
||||||
|
"credentials": {
|
||||||
|
"field_name": "spreadsheet",
|
||||||
|
"config": {"provider": "google", "type": "oauth2"},
|
||||||
|
},
|
||||||
|
"credentials2": {
|
||||||
|
"field_name": "doc_file",
|
||||||
|
"config": {"provider": "google", "type": "oauth2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
"spreadsheet": {
|
||||||
|
"_credentials_id": "cred-id-123",
|
||||||
|
"id": "file-1",
|
||||||
|
"name": "file1.xlsx",
|
||||||
|
},
|
||||||
|
"doc_file": {
|
||||||
|
"_credentials_id": None,
|
||||||
|
"id": "file-2",
|
||||||
|
"name": "chained.doc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
extra_kwargs, locks = await _acquire_auto_credentials(
|
||||||
|
input_model=input_model,
|
||||||
|
input_data=input_data,
|
||||||
|
creds_manager=manager,
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only the first field should have acquired credentials
|
||||||
|
manager.acquire.assert_called_once_with("user-1", "cred-id-123")
|
||||||
|
assert "credentials" in extra_kwargs
|
||||||
|
assert "credentials2" not in extra_kwargs
|
||||||
|
assert len(locks) == 1
|
||||||
@@ -254,7 +254,8 @@ async def _validate_node_input_credentials(
|
|||||||
|
|
||||||
# Find any fields of type CredentialsMetaInput
|
# Find any fields of type CredentialsMetaInput
|
||||||
credentials_fields = block.input_schema.get_credentials_fields()
|
credentials_fields = block.input_schema.get_credentials_fields()
|
||||||
if not credentials_fields:
|
auto_credentials_fields = block.input_schema.get_auto_credentials_fields()
|
||||||
|
if not credentials_fields and not auto_credentials_fields:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Track if any credential field is missing for this node
|
# Track if any credential field is missing for this node
|
||||||
@@ -334,6 +335,47 @@ async def _validate_node_input_credentials(
|
|||||||
] = "Invalid credentials: type/provider mismatch"
|
] = "Invalid credentials: type/provider mismatch"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Validate auto-credentials (GoogleDriveFileField-based)
|
||||||
|
# These have _credentials_id embedded in the file field data
|
||||||
|
if auto_credentials_fields:
|
||||||
|
for _kwarg_name, info in auto_credentials_fields.items():
|
||||||
|
field_name = info["field_name"]
|
||||||
|
# Check input_default and nodes_input_masks for the field value
|
||||||
|
field_value = node.input_default.get(field_name)
|
||||||
|
if nodes_input_masks and node.id in nodes_input_masks:
|
||||||
|
field_value = nodes_input_masks[node.id].get(
|
||||||
|
field_name, field_value
|
||||||
|
)
|
||||||
|
|
||||||
|
if field_value and isinstance(field_value, dict):
|
||||||
|
if "_credentials_id" not in field_value:
|
||||||
|
# Key removed (e.g., on fork) — needs re-auth
|
||||||
|
has_missing_credentials = True
|
||||||
|
credential_errors[node.id][field_name] = (
|
||||||
|
"Authentication missing for the selected file. "
|
||||||
|
"Please re-select the file to authenticate with "
|
||||||
|
"your own account."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
cred_id = field_value.get("_credentials_id")
|
||||||
|
if cred_id and isinstance(cred_id, str):
|
||||||
|
try:
|
||||||
|
creds_store = get_integration_credentials_store()
|
||||||
|
creds = await creds_store.get_creds_by_id(user_id, cred_id)
|
||||||
|
except Exception as e:
|
||||||
|
has_missing_credentials = True
|
||||||
|
credential_errors[node.id][
|
||||||
|
field_name
|
||||||
|
] = f"Credentials not available: {e}"
|
||||||
|
continue
|
||||||
|
if not creds:
|
||||||
|
has_missing_credentials = True
|
||||||
|
credential_errors[node.id][field_name] = (
|
||||||
|
"The saved credentials are not available "
|
||||||
|
"for your account. Please re-select the file to "
|
||||||
|
"authenticate with your own account."
|
||||||
|
)
|
||||||
|
|
||||||
# If node has optional credentials and any are missing, mark for skipping
|
# If node has optional credentials and any are missing, mark for skipping
|
||||||
# But only if there are no other errors for this node
|
# But only if there are no other errors for this node
|
||||||
if (
|
if (
|
||||||
@@ -365,8 +407,9 @@ def make_node_credentials_input_map(
|
|||||||
"""
|
"""
|
||||||
result: dict[str, dict[str, JsonValue]] = {}
|
result: dict[str, dict[str, JsonValue]] = {}
|
||||||
|
|
||||||
# Get aggregated credentials fields for the graph
|
# Only map regular credentials (not auto_credentials, which are resolved
|
||||||
graph_cred_inputs = graph.aggregate_credentials_inputs()
|
# at execution time from _credentials_id in file field data)
|
||||||
|
graph_cred_inputs = graph.regular_credentials_inputs
|
||||||
|
|
||||||
for graph_input_name, (_, compatible_node_fields, _) in graph_cred_inputs.items():
|
for graph_input_name, (_, compatible_node_fields, _) in graph_cred_inputs.items():
|
||||||
# Best-effort map: skip missing items
|
# Best-effort map: skip missing items
|
||||||
|
|||||||
@@ -907,3 +907,335 @@ async def test_stop_graph_execution_cascades_to_child_with_reviews(
|
|||||||
|
|
||||||
# Verify both parent and child status updates
|
# Verify both parent and child status updates
|
||||||
assert mock_execution_db.update_graph_execution_stats.call_count >= 1
|
assert mock_execution_db.update_graph_execution_stats.call_count >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for auto_credentials validation in _validate_node_input_credentials
|
||||||
|
# (Fix 3: SECRT-1772 + Fix 4: Path 4)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_node_input_credentials_auto_creds_valid(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
[SECRT-1772] When a node has auto_credentials with a valid _credentials_id
|
||||||
|
that exists in the store, validation should pass without errors.
|
||||||
|
"""
|
||||||
|
from backend.executor.utils import _validate_node_input_credentials
|
||||||
|
|
||||||
|
mock_node = mocker.MagicMock()
|
||||||
|
mock_node.id = "node-with-auto-creds"
|
||||||
|
mock_node.credentials_optional = False
|
||||||
|
mock_node.input_default = {
|
||||||
|
"spreadsheet": {
|
||||||
|
"_credentials_id": "valid-cred-id",
|
||||||
|
"id": "file-123",
|
||||||
|
"name": "test.xlsx",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_block = mocker.MagicMock()
|
||||||
|
# No regular credentials fields
|
||||||
|
mock_block.input_schema.get_credentials_fields.return_value = {}
|
||||||
|
# Has auto_credentials fields
|
||||||
|
mock_block.input_schema.get_auto_credentials_fields.return_value = {
|
||||||
|
"credentials": {
|
||||||
|
"field_name": "spreadsheet",
|
||||||
|
"config": {"provider": "google", "type": "oauth2"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_node.block = mock_block
|
||||||
|
|
||||||
|
mock_graph = mocker.MagicMock()
|
||||||
|
mock_graph.nodes = [mock_node]
|
||||||
|
|
||||||
|
# Mock the credentials store to return valid credentials
|
||||||
|
mock_store = mocker.MagicMock()
|
||||||
|
mock_creds = mocker.MagicMock()
|
||||||
|
mock_creds.id = "valid-cred-id"
|
||||||
|
mock_store.get_creds_by_id = mocker.AsyncMock(return_value=mock_creds)
|
||||||
|
mocker.patch(
|
||||||
|
"backend.executor.utils.get_integration_credentials_store",
|
||||||
|
return_value=mock_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
errors, nodes_to_skip = await _validate_node_input_credentials(
|
||||||
|
graph=mock_graph,
|
||||||
|
user_id="test-user",
|
||||||
|
nodes_input_masks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mock_node.id not in errors
|
||||||
|
assert mock_node.id not in nodes_to_skip
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_node_input_credentials_auto_creds_missing(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
[SECRT-1772] When a node has auto_credentials with a _credentials_id
|
||||||
|
that doesn't exist for the current user, validation should report an error.
|
||||||
|
"""
|
||||||
|
from backend.executor.utils import _validate_node_input_credentials
|
||||||
|
|
||||||
|
mock_node = mocker.MagicMock()
|
||||||
|
mock_node.id = "node-with-bad-auto-creds"
|
||||||
|
mock_node.credentials_optional = False
|
||||||
|
mock_node.input_default = {
|
||||||
|
"spreadsheet": {
|
||||||
|
"_credentials_id": "other-users-cred-id",
|
||||||
|
"id": "file-123",
|
||||||
|
"name": "test.xlsx",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_block = mocker.MagicMock()
|
||||||
|
mock_block.input_schema.get_credentials_fields.return_value = {}
|
||||||
|
mock_block.input_schema.get_auto_credentials_fields.return_value = {
|
||||||
|
"credentials": {
|
||||||
|
"field_name": "spreadsheet",
|
||||||
|
"config": {"provider": "google", "type": "oauth2"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_node.block = mock_block
|
||||||
|
|
||||||
|
mock_graph = mocker.MagicMock()
|
||||||
|
mock_graph.nodes = [mock_node]
|
||||||
|
|
||||||
|
# Mock the credentials store to return None (cred not found for this user)
|
||||||
|
mock_store = mocker.MagicMock()
|
||||||
|
mock_store.get_creds_by_id = mocker.AsyncMock(return_value=None)
|
||||||
|
mocker.patch(
|
||||||
|
"backend.executor.utils.get_integration_credentials_store",
|
||||||
|
return_value=mock_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
errors, nodes_to_skip = await _validate_node_input_credentials(
|
||||||
|
graph=mock_graph,
|
||||||
|
user_id="different-user",
|
||||||
|
nodes_input_masks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mock_node.id in errors
|
||||||
|
assert "spreadsheet" in errors[mock_node.id]
|
||||||
|
assert "not available" in errors[mock_node.id]["spreadsheet"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_node_input_credentials_both_regular_and_auto(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
[SECRT-1772] A node that has BOTH regular credentials AND auto_credentials
|
||||||
|
should have both validated.
|
||||||
|
"""
|
||||||
|
from backend.executor.utils import _validate_node_input_credentials
|
||||||
|
|
||||||
|
mock_node = mocker.MagicMock()
|
||||||
|
mock_node.id = "node-with-both-creds"
|
||||||
|
mock_node.credentials_optional = False
|
||||||
|
mock_node.input_default = {
|
||||||
|
"credentials": {
|
||||||
|
"id": "regular-cred-id",
|
||||||
|
"provider": "github",
|
||||||
|
"type": "api_key",
|
||||||
|
},
|
||||||
|
"spreadsheet": {
|
||||||
|
"_credentials_id": "auto-cred-id",
|
||||||
|
"id": "file-123",
|
||||||
|
"name": "test.xlsx",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_credentials_field_type = mocker.MagicMock()
|
||||||
|
mock_credentials_meta = mocker.MagicMock()
|
||||||
|
mock_credentials_meta.id = "regular-cred-id"
|
||||||
|
mock_credentials_meta.provider = "github"
|
||||||
|
mock_credentials_meta.type = "api_key"
|
||||||
|
mock_credentials_field_type.model_validate.return_value = mock_credentials_meta
|
||||||
|
|
||||||
|
mock_block = mocker.MagicMock()
|
||||||
|
# Regular credentials field
|
||||||
|
mock_block.input_schema.get_credentials_fields.return_value = {
|
||||||
|
"credentials": mock_credentials_field_type,
|
||||||
|
}
|
||||||
|
# Auto-credentials field
|
||||||
|
mock_block.input_schema.get_auto_credentials_fields.return_value = {
|
||||||
|
"auto_credentials": {
|
||||||
|
"field_name": "spreadsheet",
|
||||||
|
"config": {"provider": "google", "type": "oauth2"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_node.block = mock_block
|
||||||
|
|
||||||
|
mock_graph = mocker.MagicMock()
|
||||||
|
mock_graph.nodes = [mock_node]
|
||||||
|
|
||||||
|
# Mock the credentials store to return valid credentials for both
|
||||||
|
mock_store = mocker.MagicMock()
|
||||||
|
mock_regular_creds = mocker.MagicMock()
|
||||||
|
mock_regular_creds.id = "regular-cred-id"
|
||||||
|
mock_regular_creds.provider = "github"
|
||||||
|
mock_regular_creds.type = "api_key"
|
||||||
|
|
||||||
|
mock_auto_creds = mocker.MagicMock()
|
||||||
|
mock_auto_creds.id = "auto-cred-id"
|
||||||
|
|
||||||
|
def get_creds_side_effect(user_id, cred_id):
|
||||||
|
if cred_id == "regular-cred-id":
|
||||||
|
return mock_regular_creds
|
||||||
|
elif cred_id == "auto-cred-id":
|
||||||
|
return mock_auto_creds
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_store.get_creds_by_id = mocker.AsyncMock(side_effect=get_creds_side_effect)
|
||||||
|
mocker.patch(
|
||||||
|
"backend.executor.utils.get_integration_credentials_store",
|
||||||
|
return_value=mock_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
errors, nodes_to_skip = await _validate_node_input_credentials(
|
||||||
|
graph=mock_graph,
|
||||||
|
user_id="test-user",
|
||||||
|
nodes_input_masks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both should validate successfully - no errors
|
||||||
|
assert mock_node.id not in errors
|
||||||
|
assert mock_node.id not in nodes_to_skip
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_node_input_credentials_auto_creds_skipped_when_none(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When a node has auto_credentials but the field value has _credentials_id=None
|
||||||
|
(e.g., from upstream connection), validation should skip it without error.
|
||||||
|
"""
|
||||||
|
from backend.executor.utils import _validate_node_input_credentials
|
||||||
|
|
||||||
|
mock_node = mocker.MagicMock()
|
||||||
|
mock_node.id = "node-with-chained-auto-creds"
|
||||||
|
mock_node.credentials_optional = False
|
||||||
|
mock_node.input_default = {
|
||||||
|
"spreadsheet": {
|
||||||
|
"_credentials_id": None,
|
||||||
|
"id": "file-123",
|
||||||
|
"name": "test.xlsx",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_block = mocker.MagicMock()
|
||||||
|
mock_block.input_schema.get_credentials_fields.return_value = {}
|
||||||
|
mock_block.input_schema.get_auto_credentials_fields.return_value = {
|
||||||
|
"credentials": {
|
||||||
|
"field_name": "spreadsheet",
|
||||||
|
"config": {"provider": "google", "type": "oauth2"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_node.block = mock_block
|
||||||
|
|
||||||
|
mock_graph = mocker.MagicMock()
|
||||||
|
mock_graph.nodes = [mock_node]
|
||||||
|
|
||||||
|
errors, nodes_to_skip = await _validate_node_input_credentials(
|
||||||
|
graph=mock_graph,
|
||||||
|
user_id="test-user",
|
||||||
|
nodes_input_masks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No error - chained data with None cred_id is valid
|
||||||
|
assert mock_node.id not in errors
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for CredentialsFieldInfo auto_credential tag (Fix 4: Path 4)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_credentials_field_info_auto_credential_tag():
|
||||||
|
"""
|
||||||
|
[Path 4] CredentialsFieldInfo should support is_auto_credential and
|
||||||
|
input_field_name fields for distinguishing auto from regular credentials.
|
||||||
|
"""
|
||||||
|
from backend.data.model import CredentialsFieldInfo
|
||||||
|
|
||||||
|
# Regular credential should have is_auto_credential=False by default
|
||||||
|
regular = CredentialsFieldInfo.model_validate(
|
||||||
|
{
|
||||||
|
"credentials_provider": ["github"],
|
||||||
|
"credentials_types": ["api_key"],
|
||||||
|
},
|
||||||
|
by_alias=True,
|
||||||
|
)
|
||||||
|
assert regular.is_auto_credential is False
|
||||||
|
assert regular.input_field_name is None
|
||||||
|
|
||||||
|
# Auto credential should have is_auto_credential=True
|
||||||
|
auto = CredentialsFieldInfo.model_validate(
|
||||||
|
{
|
||||||
|
"credentials_provider": ["google"],
|
||||||
|
"credentials_types": ["oauth2"],
|
||||||
|
"is_auto_credential": True,
|
||||||
|
"input_field_name": "spreadsheet",
|
||||||
|
},
|
||||||
|
by_alias=True,
|
||||||
|
)
|
||||||
|
assert auto.is_auto_credential is True
|
||||||
|
assert auto.input_field_name == "spreadsheet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_node_credentials_input_map_excludes_auto_creds(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
[Path 4] make_node_credentials_input_map should only include regular credentials,
|
||||||
|
not auto_credentials (which are resolved at execution time).
|
||||||
|
"""
|
||||||
|
from backend.data.model import CredentialsFieldInfo, CredentialsMetaInput
|
||||||
|
from backend.executor.utils import make_node_credentials_input_map
|
||||||
|
from backend.integrations.providers import ProviderName
|
||||||
|
|
||||||
|
# Create a mock graph with aggregate_credentials_inputs that returns
|
||||||
|
# both regular and auto credentials
|
||||||
|
mock_graph = mocker.MagicMock()
|
||||||
|
|
||||||
|
regular_field_info = CredentialsFieldInfo.model_validate(
|
||||||
|
{
|
||||||
|
"credentials_provider": ["github"],
|
||||||
|
"credentials_types": ["api_key"],
|
||||||
|
"is_auto_credential": False,
|
||||||
|
},
|
||||||
|
by_alias=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock regular_credentials_inputs property (auto_credentials are excluded)
|
||||||
|
mock_graph.regular_credentials_inputs = {
|
||||||
|
"github_creds": (regular_field_info, {("node-1", "credentials")}, True),
|
||||||
|
}
|
||||||
|
|
||||||
|
graph_credentials_input = {
|
||||||
|
"github_creds": CredentialsMetaInput(
|
||||||
|
id="cred-123",
|
||||||
|
provider=ProviderName("github"),
|
||||||
|
type="api_key",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = make_node_credentials_input_map(mock_graph, graph_credentials_input)
|
||||||
|
|
||||||
|
# Regular credentials should be mapped
|
||||||
|
assert "node-1" in result
|
||||||
|
assert "credentials" in result["node-1"]
|
||||||
|
|
||||||
|
# Auto credentials should NOT appear in the result
|
||||||
|
# (they would have been mapped to the kwarg_name "credentials" not "spreadsheet")
|
||||||
|
for node_id, fields in result.items():
|
||||||
|
for field_name, value in fields.items():
|
||||||
|
# Verify no auto-credential phantom entries
|
||||||
|
if isinstance(value, dict):
|
||||||
|
assert "_credentials_id" not in value
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `search` on the `StoreListingVersion` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "UnifiedContentEmbedding_search_idx";
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "LibraryAgent" ADD COLUMN "folderId" TEXT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "StoreListingVersion" DROP COLUMN "search";
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "LibraryFolder" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"icon" TEXT,
|
|
||||||
"color" TEXT,
|
|
||||||
"parentId" TEXT,
|
|
||||||
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT "LibraryFolder_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "LibraryFolder_userId_isDeleted_idx" ON "LibraryFolder"("userId", "isDeleted");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "LibraryFolder_parentId_idx" ON "LibraryFolder"("parentId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "LibraryFolder_userId_parentId_name_key" ON "LibraryFolder"("userId", "parentId", "name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "LibraryAgent_folderId_idx" ON "LibraryAgent"("folderId");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "LibraryFolder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "LibraryFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -51,7 +51,6 @@ model User {
|
|||||||
ChatSessions ChatSession[]
|
ChatSessions ChatSession[]
|
||||||
AgentPresets AgentPreset[]
|
AgentPresets AgentPreset[]
|
||||||
LibraryAgents LibraryAgent[]
|
LibraryAgents LibraryAgent[]
|
||||||
LibraryFolders LibraryFolder[]
|
|
||||||
|
|
||||||
Profile Profile[]
|
Profile Profile[]
|
||||||
UserOnboarding UserOnboarding?
|
UserOnboarding UserOnboarding?
|
||||||
@@ -396,9 +395,6 @@ model LibraryAgent {
|
|||||||
creatorId String?
|
creatorId String?
|
||||||
Creator Profile? @relation(fields: [creatorId], references: [id])
|
Creator Profile? @relation(fields: [creatorId], references: [id])
|
||||||
|
|
||||||
folderId String?
|
|
||||||
Folder LibraryFolder? @relation(fields: [folderId], references: [id], onDelete: Restrict)
|
|
||||||
|
|
||||||
useGraphIsActiveVersion Boolean @default(false)
|
useGraphIsActiveVersion Boolean @default(false)
|
||||||
|
|
||||||
isFavorite Boolean @default(false)
|
isFavorite Boolean @default(false)
|
||||||
@@ -411,32 +407,6 @@ model LibraryAgent {
|
|||||||
@@unique([userId, agentGraphId, agentGraphVersion])
|
@@unique([userId, agentGraphId, agentGraphVersion])
|
||||||
@@index([agentGraphId, agentGraphVersion])
|
@@index([agentGraphId, agentGraphVersion])
|
||||||
@@index([creatorId])
|
@@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])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ Sentry.init({
|
|||||||
|
|
||||||
enabled: shouldEnable,
|
enabled: shouldEnable,
|
||||||
|
|
||||||
// Suppress cross-origin stylesheet errors from Sentry Replay (rrweb)
|
|
||||||
// serializing DOM snapshots with cross-origin stylesheets
|
|
||||||
// (e.g., from browser extensions or CDN-loaded CSS)
|
|
||||||
ignoreErrors: [/Not allowed to access cross-origin stylesheet/],
|
|
||||||
|
|
||||||
// Add optional integrations for additional features
|
// Add optional integrations for additional features
|
||||||
integrations: [
|
integrations: [
|
||||||
Sentry.captureConsoleIntegration(),
|
Sentry.captureConsoleIntegration(),
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "3.0.61",
|
"@ai-sdk/react": "3.0.61",
|
||||||
"@faker-js/faker": "10.0.0",
|
"@faker-js/faker": "10.0.0",
|
||||||
"@ferrucc-io/emoji-picker": "0.0.48",
|
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@next/third-parties": "15.4.6",
|
"@next/third-parties": "15.4.6",
|
||||||
"@phosphor-icons/react": "2.1.10",
|
"@phosphor-icons/react": "2.1.10",
|
||||||
|
|||||||
116
autogpt_platform/frontend/pnpm-lock.yaml
generated
116
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -18,9 +18,6 @@ importers:
|
|||||||
'@faker-js/faker':
|
'@faker-js/faker':
|
||||||
specifier: 10.0.0
|
specifier: 10.0.0
|
||||||
version: 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':
|
'@hookform/resolvers':
|
||||||
specifier: 5.2.2
|
specifier: 5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.66.0(react@18.3.1))
|
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==}
|
resolution: {integrity: sha512-UollFEUkVXutsaP+Vndjxar40Gs5JL2HeLcl8xO1QAjJgOdhc3OmBFWyEylS+RddWaaBiAzH+5/17PLQJwDiLw==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
|
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':
|
'@floating-ui/core@1.7.3':
|
||||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||||
|
|
||||||
@@ -3125,10 +3114,6 @@ packages:
|
|||||||
'@shikijs/vscode-textmate@10.0.2':
|
'@shikijs/vscode-textmate@10.0.2':
|
||||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
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':
|
'@standard-schema/spec@1.0.0':
|
||||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||||
|
|
||||||
@@ -3396,19 +3381,10 @@ packages:
|
|||||||
react: '>=16.8'
|
react: '>=16.8'
|
||||||
react-dom: '>=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':
|
'@tanstack/table-core@8.21.3':
|
||||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
'@tanstack/virtual-core@3.13.18':
|
|
||||||
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4402,10 +4378,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
char-regex@1.0.2:
|
|
||||||
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
|
|
||||||
engines: {node: '>=10'}
|
|
||||||
|
|
||||||
character-entities-html4@2.1.0:
|
character-entities-html4@2.1.0:
|
||||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||||
|
|
||||||
@@ -5023,9 +4995,6 @@ packages:
|
|||||||
emoji-regex@9.2.2:
|
emoji-regex@9.2.2:
|
||||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||||
|
|
||||||
emojilib@2.4.0:
|
|
||||||
resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==}
|
|
||||||
|
|
||||||
emojis-list@3.0.0:
|
emojis-list@3.0.0:
|
||||||
resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==}
|
resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -6006,24 +5975,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
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:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -6642,10 +6593,6 @@ packages:
|
|||||||
node-abort-controller@3.1.1:
|
node-abort-controller@3.1.1:
|
||||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
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:
|
node-fetch-h2@2.3.0:
|
||||||
resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==}
|
resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@@ -7744,10 +7691,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-LH7FpTAkeD+y5xQC4fzS+tFtaNlvt3Ib1zKzvhjv/Y+cioV4zIuw4IZr2yhRLu67CWL7FR9/6KXKnjRoZTvGGQ==}
|
resolution: {integrity: sha512-LH7FpTAkeD+y5xQC4fzS+tFtaNlvt3Ib1zKzvhjv/Y+cioV4zIuw4IZr2yhRLu67CWL7FR9/6KXKnjRoZTvGGQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
skin-tone@2.0.0:
|
|
||||||
resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
slash@3.0.0:
|
slash@3.0.0:
|
||||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -8225,13 +8168,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
|
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
|
||||||
engines: {node: '>=4'}
|
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:
|
unicode-match-property-ecmascript@2.0.0:
|
||||||
resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==}
|
resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -9841,22 +9777,6 @@ snapshots:
|
|||||||
|
|
||||||
'@faker-js/faker@10.0.0': {}
|
'@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':
|
'@floating-ui/core@1.7.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.10
|
'@floating-ui/utils': 0.2.10
|
||||||
@@ -11618,8 +11538,6 @@ snapshots:
|
|||||||
|
|
||||||
'@shikijs/vscode-textmate@10.0.2': {}
|
'@shikijs/vscode-textmate@10.0.2': {}
|
||||||
|
|
||||||
'@sindresorhus/is@4.6.0': {}
|
|
||||||
|
|
||||||
'@standard-schema/spec@1.0.0': {}
|
'@standard-schema/spec@1.0.0': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
@@ -12093,16 +12011,8 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(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/table-core@8.21.3': {}
|
||||||
|
|
||||||
'@tanstack/virtual-core@3.13.18': {}
|
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.27.1
|
'@babel/code-frame': 7.27.1
|
||||||
@@ -13194,8 +13104,6 @@ snapshots:
|
|||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
|
|
||||||
char-regex@1.0.2: {}
|
|
||||||
|
|
||||||
character-entities-html4@2.1.0: {}
|
character-entities-html4@2.1.0: {}
|
||||||
|
|
||||||
character-entities-legacy@3.0.0: {}
|
character-entities-legacy@3.0.0: {}
|
||||||
@@ -13839,8 +13747,6 @@ snapshots:
|
|||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
|
|
||||||
emojilib@2.4.0: {}
|
|
||||||
|
|
||||||
emojis-list@3.0.0: {}
|
emojis-list@3.0.0: {}
|
||||||
|
|
||||||
endent@2.1.0:
|
endent@2.1.0:
|
||||||
@@ -15122,13 +15028,6 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.6.1: {}
|
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-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.0:
|
js-yaml@4.1.0:
|
||||||
@@ -15997,13 +15896,6 @@ snapshots:
|
|||||||
|
|
||||||
node-abort-controller@3.1.1: {}
|
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:
|
node-fetch-h2@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
http2-client: 1.3.5
|
http2-client: 1.3.5
|
||||||
@@ -17304,10 +17196,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
jsep: 1.4.0
|
jsep: 1.4.0
|
||||||
|
|
||||||
skin-tone@2.0.0:
|
|
||||||
dependencies:
|
|
||||||
unicode-emoji-modifier-base: 1.0.0
|
|
||||||
|
|
||||||
slash@3.0.0: {}
|
slash@3.0.0: {}
|
||||||
|
|
||||||
sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
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-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:
|
unicode-match-property-ecmascript@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
unicode-canonical-property-names-ecmascript: 2.0.1
|
unicode-canonical-property-names-ecmascript: 2.0.1
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export function ScheduleListItem({
|
|||||||
description={formatDistanceToNow(schedule.next_run_time, {
|
description={formatDistanceToNow(schedule.next_run_time, {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
})}
|
})}
|
||||||
descriptionTitle={new Date(schedule.next_run_time).toString()}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
icon={
|
icon={
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import React from "react";
|
|||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
descriptionTitle?: string;
|
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -17,7 +16,6 @@ interface Props {
|
|||||||
export function SidebarItemCard({
|
export function SidebarItemCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
descriptionTitle,
|
|
||||||
icon,
|
icon,
|
||||||
selected,
|
selected,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -40,11 +38,7 @@ export function SidebarItemCard({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text variant="body" className="leading-tight !text-zinc-500">
|
||||||
variant="body"
|
|
||||||
className="leading-tight !text-zinc-500"
|
|
||||||
title={descriptionTitle}
|
|
||||||
>
|
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,9 +81,6 @@ export function TaskListItem({
|
|||||||
? formatDistanceToNow(run.started_at, { addSuffix: true })
|
? formatDistanceToNow(run.started_at, { addSuffix: true })
|
||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
descriptionTitle={
|
|
||||||
run.started_at ? new Date(run.started_at).toString() : undefined
|
|
||||||
}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
|
||||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||||
import { HeartIcon } from "@phosphor-icons/react";
|
import { HeartIcon } from "@phosphor-icons/react";
|
||||||
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||||
import { LibraryTabs, Tab } from "../LibraryTabs/LibraryTabs";
|
|
||||||
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchTerm: string;
|
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 {
|
const {
|
||||||
allAgents: favoriteAgents,
|
allAgents: favoriteAgents,
|
||||||
agentLoading: isLoading,
|
agentLoading: isLoading,
|
||||||
@@ -29,26 +21,38 @@ export function FavoritesSection({ searchTerm, tabs, activeTab, onTabChange, set
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useFavoriteAgents({ searchTerm });
|
} = useFavoriteAgents({ searchTerm });
|
||||||
|
|
||||||
return (
|
if (isLoading || favoriteAgents.length === 0) {
|
||||||
<>
|
return null;
|
||||||
<LibraryActionSubHeader agentCount={agentCount} setLibrarySort={setLibrarySort} />
|
}
|
||||||
<LibraryTabs tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} />
|
|
||||||
|
|
||||||
{isLoading ? (
|
return (
|
||||||
<div className="flex h-[200px] items-center justify-center">
|
<div className="!mb-8">
|
||||||
<LoadingSpinner size="large" />
|
<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>
|
</div>
|
||||||
) : favoriteAgents.length === 0 ? (
|
</div>
|
||||||
<div className="flex h-[200px] flex-col items-center justify-center gap-2 text-zinc-500">
|
|
||||||
<HeartIcon className="h-10 w-10" />
|
<div className="relative">
|
||||||
<Text variant="body">No favorite agents yet</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
fetchNextPage={fetchNextPage}
|
fetchNextPage={fetchNextPage}
|
||||||
hasNextPage={hasNextPage}
|
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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{favoriteAgents.map((agent: LibraryAgent) => (
|
{favoriteAgents.map((agent: LibraryAgent) => (
|
||||||
@@ -56,7 +60,9 @@ export function FavoritesSection({ searchTerm, tabs, activeTab, onTabChange, set
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
)}
|
</div>
|
||||||
</>
|
|
||||||
|
{favoriteAgents.length > 0 && <div className="!mt-10 border-t" />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { HeartIcon } from "@phosphor-icons/react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface FlyingHeartProps {
|
|
||||||
startPosition: { x: number; y: number } | null;
|
|
||||||
targetPosition: { x: number; y: number } | null;
|
|
||||||
onAnimationComplete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FlyingHeart({
|
|
||||||
startPosition,
|
|
||||||
targetPosition,
|
|
||||||
onAnimationComplete,
|
|
||||||
}: FlyingHeartProps) {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (startPosition && targetPosition) {
|
|
||||||
setIsVisible(true);
|
|
||||||
}
|
|
||||||
}, [startPosition, targetPosition]);
|
|
||||||
|
|
||||||
if (!startPosition || !targetPosition) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{isVisible && (
|
|
||||||
<motion.div
|
|
||||||
className="pointer-events-none fixed z-50"
|
|
||||||
initial={{
|
|
||||||
x: startPosition.x,
|
|
||||||
y: startPosition.y,
|
|
||||||
scale: 1,
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
x: targetPosition.x,
|
|
||||||
y: targetPosition.y,
|
|
||||||
scale: 0.5,
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{
|
|
||||||
type: "spring",
|
|
||||||
damping: 20,
|
|
||||||
stiffness: 200,
|
|
||||||
duration: 0.5,
|
|
||||||
}}
|
|
||||||
onAnimationComplete={() => {
|
|
||||||
setIsVisible(false);
|
|
||||||
onAnimationComplete();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HeartIcon
|
|
||||||
size={24}
|
|
||||||
weight="fill"
|
|
||||||
className="text-red-500 drop-shadow-md"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ export function LibraryActionSubHeader({ agentCount, setLibrarySort }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-baseline gap-4">
|
<div className="flex items-baseline gap-4">
|
||||||
<Text variant="h5">My agents</Text>
|
<Text variant="h4">My agents</Text>
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
data-testid="agents-count"
|
data-testid="agents-count"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Text } from "@/components/atoms/Text/Text";
|
|||||||
import { CaretCircleRightIcon } from "@phosphor-icons/react";
|
import { CaretCircleRightIcon } from "@phosphor-icons/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import NextLink from "next/link";
|
import NextLink from "next/link";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||||
import Avatar, {
|
import Avatar, {
|
||||||
@@ -15,24 +14,13 @@ import { Link } from "@/components/atoms/Link/Link";
|
|||||||
import { AgentCardMenu } from "./components/AgentCardMenu";
|
import { AgentCardMenu } from "./components/AgentCardMenu";
|
||||||
import { FavoriteButton } from "./components/FavoriteButton";
|
import { FavoriteButton } from "./components/FavoriteButton";
|
||||||
import { useLibraryAgentCard } from "./useLibraryAgentCard";
|
import { useLibraryAgentCard } from "./useLibraryAgentCard";
|
||||||
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: LibraryAgent;
|
agent: LibraryAgent;
|
||||||
draggable?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryAgentCard({
|
export function LibraryAgentCard({ agent }: Props) {
|
||||||
agent,
|
|
||||||
draggable = true,
|
|
||||||
}: Props) {
|
|
||||||
const { id, name, graph_id, can_access_graph, image_url } = agent;
|
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 {
|
const {
|
||||||
isFromMarketplace,
|
isFromMarketplace,
|
||||||
@@ -40,29 +28,14 @@ export function LibraryAgentCard({
|
|||||||
profile,
|
profile,
|
||||||
creator_image_url,
|
creator_image_url,
|
||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
} = useLibraryAgentCard({
|
} = useLibraryAgentCard({ agent });
|
||||||
agent,
|
|
||||||
onFavoriteAdd: triggerFavoriteAnimation,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable={draggable}
|
data-testid="library-agent-card"
|
||||||
onDragStart={handleDragStart}
|
data-agent-id={id}
|
||||||
className="cursor-grab active:cursor-grabbing"
|
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">
|
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
|
||||||
<div className="relative flex items-center gap-2 px-4 pt-3">
|
<div className="relative flex items-center gap-2 px-4 pt-3">
|
||||||
<Avatar className="h-4 w-4 rounded-full">
|
<Avatar className="h-4 w-4 rounded-full">
|
||||||
@@ -152,7 +125,6 @@ export function LibraryAgentCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import {
|
|||||||
useDeleteV2DeleteLibraryAgent,
|
useDeleteV2DeleteLibraryAgent,
|
||||||
usePostV2ForkLibraryAgent,
|
usePostV2ForkLibraryAgent,
|
||||||
} from "@/app/api/__generated__/endpoints/library/library";
|
} 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 { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
@@ -26,7 +22,6 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MoveToFolderDialog } from "../../MoveToFolderDialog/MoveToFolderDialog";
|
|
||||||
|
|
||||||
interface AgentCardMenuProps {
|
interface AgentCardMenuProps {
|
||||||
agent: LibraryAgent;
|
agent: LibraryAgent;
|
||||||
@@ -37,25 +32,11 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showMoveDialog, setShowMoveDialog] = useState(false);
|
|
||||||
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
|
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
|
||||||
const [isDuplicatingAgent, setIsDuplicatingAgent] = useState(false);
|
const [isDuplicatingAgent, setIsDuplicatingAgent] = useState(false);
|
||||||
const [isRemovingFromFolder, setIsRemovingFromFolder] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
|
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
|
||||||
const { mutateAsync: forkAgent } = usePostV2ForkLibraryAgent();
|
const { mutateAsync: forkAgent } = usePostV2ForkLibraryAgent();
|
||||||
const { mutateAsync: bulkMoveAgents } = usePostV2BulkMoveAgents({
|
|
||||||
mutation: {
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleDuplicateAgent() {
|
async function handleDuplicateAgent() {
|
||||||
if (!agent.id) return;
|
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() {
|
async function handleDeleteAgent() {
|
||||||
if (!agent.id) return;
|
if (!agent.id) return;
|
||||||
|
|
||||||
@@ -188,31 +138,6 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
|
|||||||
Duplicate agent
|
Duplicate agent
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<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
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -258,14 +183,6 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<MoveToFolderDialog
|
|
||||||
agentId={agent.id}
|
|
||||||
agentName={agent.name}
|
|
||||||
currentFolderId={agent.folder_id}
|
|
||||||
isOpen={showMoveDialog}
|
|
||||||
setIsOpen={setShowMoveDialog}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { HeartIcon } from "@phosphor-icons/react";
|
import { HeartIcon } from "@phosphor-icons/react";
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { useRef } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
|
|
||||||
interface FavoriteButtonProps {
|
interface FavoriteButtonProps {
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
onClick: (e: MouseEvent<HTMLButtonElement>, position: { x: number; y: number }) => void;
|
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,46 +15,25 @@ export function FavoriteButton({
|
|||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}: FavoriteButtonProps) {
|
}: 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
onClick={onClick}
|
||||||
onClick={handleClick}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full p-2 transition-all duration-200",
|
"rounded-full p-2 transition-all duration-200",
|
||||||
"hover:scale-110 active:scale-95",
|
"hover:scale-110",
|
||||||
!isFavorite && "opacity-0 group-hover:opacity-100",
|
!isFavorite && "opacity-0 group-hover:opacity-100",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
<HeartIcon
|
||||||
<motion.div
|
size={20}
|
||||||
key={isFavorite ? "filled" : "empty"}
|
weight={isFavorite ? "fill" : "regular"}
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
className={cn(
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
"transition-colors duration-200",
|
||||||
exit={{ scale: 0.5, opacity: 0 }}
|
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { updateFavoriteInQueries } from "./helpers";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: LibraryAgent;
|
agent: LibraryAgent;
|
||||||
onFavoriteAdd?: (position: { x: number; y: number }) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLibraryAgentCard({ agent, onFavoriteAdd }: Props) {
|
export function useLibraryAgentCard({ agent }: Props) {
|
||||||
const { id, is_favorite, creator_image_url, marketplace_listing } = agent;
|
const { id, name, is_favorite, creator_image_url, marketplace_listing } =
|
||||||
|
agent;
|
||||||
|
|
||||||
const isFromMarketplace = Boolean(marketplace_listing);
|
const isFromMarketplace = Boolean(marketplace_listing);
|
||||||
const [isFavorite, setIsFavorite] = useState(is_favorite);
|
const [isFavorite, setIsFavorite] = useState(is_favorite);
|
||||||
@@ -49,31 +49,26 @@ export function useLibraryAgentCard({ agent, onFavoriteAdd }: Props) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleFavorite(
|
async function handleToggleFavorite(e: React.MouseEvent) {
|
||||||
e: React.MouseEvent,
|
|
||||||
position: { x: number; y: number }
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const newIsFavorite = !isFavorite;
|
const newIsFavorite = !isFavorite;
|
||||||
|
|
||||||
// Optimistic update - update UI immediately
|
|
||||||
setIsFavorite(newIsFavorite);
|
setIsFavorite(newIsFavorite);
|
||||||
updateQueryData(newIsFavorite);
|
updateQueryData(newIsFavorite);
|
||||||
|
|
||||||
// Trigger animation immediately for adding to favorites
|
|
||||||
if (newIsFavorite && onFavoriteAdd) {
|
|
||||||
onFavoriteAdd(position);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateLibraryAgent({
|
await updateLibraryAgent({
|
||||||
libraryAgentId: id,
|
libraryAgentId: id,
|
||||||
data: { is_favorite: newIsFavorite },
|
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 {
|
} catch {
|
||||||
// Revert on failure
|
|
||||||
setIsFavorite(!newIsFavorite);
|
setIsFavorite(!newIsFavorite);
|
||||||
updateQueryData(!newIsFavorite);
|
updateQueryData(!newIsFavorite);
|
||||||
|
|
||||||
|
|||||||
@@ -1,130 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||||
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
||||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
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";
|
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 {
|
interface Props {
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
librarySort: LibraryAgentSort;
|
librarySort: LibraryAgentSort;
|
||||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||||
selectedFolderId: string | null;
|
|
||||||
onFolderSelect: (folderId: string | null) => void;
|
|
||||||
tabs: Tab[];
|
|
||||||
activeTab: string;
|
|
||||||
onTabChange: (tabId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryAgentList({
|
export function LibraryAgentList({
|
||||||
searchTerm,
|
searchTerm,
|
||||||
librarySort,
|
librarySort,
|
||||||
setLibrarySort,
|
setLibrarySort,
|
||||||
selectedFolderId,
|
|
||||||
onFolderSelect,
|
|
||||||
tabs,
|
|
||||||
activeTab,
|
|
||||||
onTabChange,
|
|
||||||
}: Props) {
|
}: 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 {
|
const {
|
||||||
isFavoritesTab,
|
|
||||||
agentLoading,
|
agentLoading,
|
||||||
agentCount,
|
agentCount,
|
||||||
agents,
|
allAgents: agents,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
foldersData,
|
} = useLibraryAgentList({ searchTerm, librarySort });
|
||||||
currentFolder,
|
|
||||||
showFolders,
|
|
||||||
editingFolder,
|
|
||||||
setEditingFolder,
|
|
||||||
deletingFolder,
|
|
||||||
setDeletingFolder,
|
|
||||||
handleAgentDrop,
|
|
||||||
handleFolderDeleted,
|
|
||||||
} = useLibraryAgentList({
|
|
||||||
searchTerm,
|
|
||||||
librarySort,
|
|
||||||
selectedFolderId,
|
|
||||||
onFolderSelect,
|
|
||||||
activeTab,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -132,42 +32,11 @@ export function LibraryAgentList({
|
|||||||
agentCount={agentCount}
|
agentCount={agentCount}
|
||||||
setLibrarySort={setLibrarySort}
|
setLibrarySort={setLibrarySort}
|
||||||
/>
|
/>
|
||||||
{!selectedFolderId && (
|
<div className="px-2">
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{agentLoading ? (
|
{agentLoading ? (
|
||||||
<div className="flex h-[200px] items-center justify-center">
|
<div className="flex h-[200px] items-center justify-center">
|
||||||
<LoadingSpinner size="large" />
|
<LoadingSpinner size="large" />
|
||||||
</div>
|
</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
|
<InfiniteScroll
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
@@ -175,83 +44,14 @@ export function LibraryAgentList({
|
|||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
loader={<LoadingSpinner size="medium" />}
|
loader={<LoadingSpinner size="medium" />}
|
||||||
>
|
>
|
||||||
<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">
|
||||||
<AnimatePresence mode="popLayout">
|
{agents.map((agent) => (
|
||||||
<motion.div
|
<LibraryAgentCard key={agent.id} agent={agent} />
|
||||||
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"
|
</div>
|
||||||
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>
|
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingFolder && (
|
|
||||||
<LibraryFolderEditDialog
|
|
||||||
folder={editingFolder}
|
|
||||||
isOpen={!!editingFolder}
|
|
||||||
setIsOpen={(open) => {
|
|
||||||
if (!open) setEditingFolder(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deletingFolder && (
|
|
||||||
<LibraryFolderDeleteDialog
|
|
||||||
folder={deletingFolder}
|
|
||||||
isOpen={!!deletingFolder}
|
|
||||||
setIsOpen={(open) => {
|
|
||||||
if (!open) setDeletingFolder(null);
|
|
||||||
}}
|
|
||||||
onDeleted={handleFolderDeleted}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
|
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 { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||||
import {
|
import {
|
||||||
okData,
|
|
||||||
getPaginatedTotalCount,
|
getPaginatedTotalCount,
|
||||||
getPaginationNextPageNumber,
|
getPaginationNextPageNumber,
|
||||||
unpaginate,
|
unpaginate,
|
||||||
} from "@/app/api/helpers";
|
} 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 { getQueryClient } from "@/lib/react-query/queryClient";
|
||||||
import { keepPreviousData, useQueryClient } from "@tanstack/react-query";
|
import { useEffect, useRef } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
librarySort: LibraryAgentSort;
|
librarySort: LibraryAgentSort;
|
||||||
selectedFolderId: string | null;
|
|
||||||
onFolderSelect: (folderId: string | null) => void;
|
|
||||||
activeTab: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLibraryAgentList({
|
export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
|
||||||
searchTerm,
|
const queryClient = getQueryClient();
|
||||||
librarySort,
|
|
||||||
selectedFolderId,
|
|
||||||
onFolderSelect,
|
|
||||||
activeTab,
|
|
||||||
}: Props) {
|
|
||||||
const isFavoritesTab = activeTab === "favorites";
|
|
||||||
const { toast } = useToast();
|
|
||||||
const stableQueryClient = getQueryClient();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const prevSortRef = useRef<LibraryAgentSort | null>(null);
|
const prevSortRef = useRef<LibraryAgentSort | null>(null);
|
||||||
|
|
||||||
const [editingFolder, setEditingFolder] = useState<LibraryFolder | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [deletingFolder, setDeletingFolder] = useState<LibraryFolder | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: agentsQueryData,
|
data: agentsQueryData,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
isLoading: allAgentsLoading,
|
isLoading: agentLoading,
|
||||||
} = useGetV2ListLibraryAgentsInfinite(
|
} = useGetV2ListLibraryAgentsInfinite(
|
||||||
{
|
{
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
search_term: searchTerm || undefined,
|
search_term: searchTerm || undefined,
|
||||||
sort_by: librarySort,
|
sort_by: librarySort,
|
||||||
folder_id: selectedFolderId ?? undefined,
|
|
||||||
include_root_only: selectedFolderId === null ? true : undefined,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
@@ -72,152 +39,28 @@ export function useLibraryAgentList({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reset queries when sort changes to ensure fresh data with correct sorting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevSortRef.current !== null && prevSortRef.current !== librarySort) {
|
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"],
|
queryKey: ["/api/library/agents"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
prevSortRef.current = librarySort;
|
prevSortRef.current = librarySort;
|
||||||
}, [librarySort, stableQueryClient]);
|
}, [librarySort, queryClient]);
|
||||||
|
|
||||||
const allAgentsList = agentsQueryData
|
const allAgents = agentsQueryData
|
||||||
? unpaginate(agentsQueryData, "agents")
|
? unpaginate(agentsQueryData, "agents")
|
||||||
: [];
|
: [];
|
||||||
const allAgentsCount = getPaginatedTotalCount(agentsQueryData);
|
const agentCount = 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isFavoritesTab,
|
allAgents,
|
||||||
agentLoading,
|
agentLoading,
|
||||||
|
hasNextPage,
|
||||||
agentCount,
|
agentCount,
|
||||||
agents,
|
isFetchingNextPage,
|
||||||
hasNextPage: agentsHasNextPage,
|
fetchNextPage,
|
||||||
isFetchingNextPage: agentsIsFetchingNextPage,
|
|
||||||
fetchNextPage: agentsFetchNextPage,
|
|
||||||
foldersData,
|
|
||||||
currentFolder,
|
|
||||||
showFolders,
|
|
||||||
editingFolder,
|
|
||||||
setEditingFolder,
|
|
||||||
deletingFolder,
|
|
||||||
setDeletingFolder,
|
|
||||||
handleAgentDrop,
|
|
||||||
handleFolderDeleted,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import {
|
|
||||||
FolderIcon,
|
|
||||||
FolderColor,
|
|
||||||
folderCardStyles,
|
|
||||||
resolveColor,
|
|
||||||
} from "./FolderIcon";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
agentCount: number;
|
|
||||||
color?: FolderColor;
|
|
||||||
icon: string;
|
|
||||||
onEdit?: () => void;
|
|
||||||
onDelete?: () => void;
|
|
||||||
onAgentDrop?: (agentId: string, folderId: string) => void;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibraryFolder({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
agentCount,
|
|
||||||
color,
|
|
||||||
icon,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onAgentDrop,
|
|
||||||
onClick,
|
|
||||||
}: Props) {
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
const resolvedColor = resolveColor(color);
|
|
||||||
const cardStyle = folderCardStyles[resolvedColor];
|
|
||||||
|
|
||||||
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
|
|
||||||
if (e.dataTransfer.types.includes("application/agent-id")) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = "move";
|
|
||||||
setIsDragOver(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragLeave() {
|
|
||||||
setIsDragOver(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(false);
|
|
||||||
const agentId = e.dataTransfer.getData("application/agent-id");
|
|
||||||
if (agentId && onAgentDrop) {
|
|
||||||
onAgentDrop(agentId, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid="library-folder"
|
|
||||||
data-folder-id={id}
|
|
||||||
className={`group relative inline-flex h-[10.625rem] w-full max-w-[25rem] cursor-pointer flex-col items-start justify-between gap-2.5 rounded-medium border p-4 transition-all duration-200 hover:shadow-md ${
|
|
||||||
isDragOver
|
|
||||||
? "border-blue-400 bg-blue-50 ring-2 ring-blue-200"
|
|
||||||
: `${cardStyle.border} ${cardStyle.bg}`
|
|
||||||
}`}
|
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4">
|
|
||||||
{/* Left side - Folder name and agent count */}
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Text
|
|
||||||
variant="h5"
|
|
||||||
data-testid="library-folder-name"
|
|
||||||
className="line-clamp-2 hyphens-auto break-words"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
variant="small"
|
|
||||||
className="text-zinc-500"
|
|
||||||
data-testid="library-folder-agent-count"
|
|
||||||
>
|
|
||||||
{agentCount} {agentCount === 1 ? "agent" : "agents"}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side - Custom folder icon */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<FolderIcon isOpen={isHovered} color={color} icon={icon} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons - visible on hover */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-end gap-2"
|
|
||||||
data-testid="library-folder-actions"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="icon"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Edit agent"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit?.();
|
|
||||||
}}
|
|
||||||
className="h-8 w-8 p-2"
|
|
||||||
>
|
|
||||||
<PencilSimpleIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="icon"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Delete agent"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete?.();
|
|
||||||
}}
|
|
||||||
className="h-8 w-8 p-2 hover:border-red-300 hover:bg-red-50 hover:text-red-600"
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
import {
|
|
||||||
useDeleteV2DeleteFolder,
|
|
||||||
getGetV2ListLibraryFoldersQueryKey,
|
|
||||||
} from "@/app/api/__generated__/endpoints/folders/folders";
|
|
||||||
import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { LibraryFolder } from "@/app/api/__generated__/models/libraryFolder";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
folder: LibraryFolder;
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: (open: boolean) => void;
|
|
||||||
onDeleted?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibraryFolderDeleteDialog({
|
|
||||||
folder,
|
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
|
||||||
onDeleted,
|
|
||||||
}: Props) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutate: deleteFolder, isPending } = useDeleteV2DeleteFolder({
|
|
||||||
mutation: {
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: "Folder deleted",
|
|
||||||
description: `"${folder.name}" has been deleted.`,
|
|
||||||
});
|
|
||||||
setIsOpen(false);
|
|
||||||
onDeleted?.();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to delete folder. Please try again.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
deleteFolder({ folderId: folder.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
controlled={{
|
|
||||||
isOpen,
|
|
||||||
set: setIsOpen,
|
|
||||||
}}
|
|
||||||
styling={{ maxWidth: "32rem" }}
|
|
||||||
title="Delete folder"
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
<div>
|
|
||||||
<Text variant="large">
|
|
||||||
Are you sure you want to delete “{folder.name}”? Agents
|
|
||||||
inside this folder will be moved back to your library.
|
|
||||||
</Text>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDelete}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
Delete Folder
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Input } from "@/components/atoms/Input/Input";
|
|
||||||
import { Select } from "@/components/atoms/Select/Select";
|
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/molecules/Form/Form";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { EmojiPicker } from "@ferrucc-io/emoji-picker";
|
|
||||||
import {
|
|
||||||
usePatchV2UpdateFolder,
|
|
||||||
getGetV2ListLibraryFoldersQueryKey,
|
|
||||||
} from "@/app/api/__generated__/endpoints/folders/folders";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { LibraryFolder } from "@/app/api/__generated__/models/libraryFolder";
|
|
||||||
import type { getV2ListLibraryFoldersResponseSuccess } from "@/app/api/__generated__/endpoints/folders/folders";
|
|
||||||
import { ApiError } from "@/lib/autogpt-server-api/helpers";
|
|
||||||
|
|
||||||
const FOLDER_COLORS = [
|
|
||||||
{ value: "#3B82F6", label: "Blue" },
|
|
||||||
{ value: "#A855F7", label: "Purple" },
|
|
||||||
{ value: "#10B981", label: "Green" },
|
|
||||||
{ value: "#F97316", label: "Orange" },
|
|
||||||
{ value: "#EC4899", label: "Pink" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const editFolderSchema = z.object({
|
|
||||||
folderName: z.string().min(1, "Folder name is required"),
|
|
||||||
folderColor: z.string().min(1, "Folder color is required"),
|
|
||||||
folderIcon: z.string().min(1, "Folder icon is required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
folder: LibraryFolder;
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibraryFolderEditDialog({ folder, isOpen, setIsOpen }: Props) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof editFolderSchema>>({
|
|
||||||
resolver: zodResolver(editFolderSchema),
|
|
||||||
defaultValues: {
|
|
||||||
folderName: folder.name,
|
|
||||||
folderColor: folder.color ?? "",
|
|
||||||
folderIcon: folder.icon ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
form.reset({
|
|
||||||
folderName: folder.name,
|
|
||||||
folderColor: folder.color ?? "",
|
|
||||||
folderIcon: folder.icon ?? "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isOpen, folder, form]);
|
|
||||||
|
|
||||||
const { mutate: updateFolder, isPending } = usePatchV2UpdateFolder({
|
|
||||||
mutation: {
|
|
||||||
onMutate: async ({ folderId, data }) => {
|
|
||||||
await queryClient.cancelQueries({
|
|
||||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const previousData =
|
|
||||||
queryClient.getQueriesData<getV2ListLibraryFoldersResponseSuccess>({
|
|
||||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.setQueriesData<getV2ListLibraryFoldersResponseSuccess>(
|
|
||||||
{ queryKey: getGetV2ListLibraryFoldersQueryKey() },
|
|
||||||
(old) => {
|
|
||||||
if (!old?.data?.folders) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
data: {
|
|
||||||
...old.data,
|
|
||||||
folders: old.data.folders.map((f) =>
|
|
||||||
f.id === folderId
|
|
||||||
? {
|
|
||||||
...f,
|
|
||||||
name: data.name ?? f.name,
|
|
||||||
color: data.color ?? f.color,
|
|
||||||
icon: data.icon ?? f.icon,
|
|
||||||
}
|
|
||||||
: f,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return { previousData };
|
|
||||||
},
|
|
||||||
onError: (error: unknown, _variables, context) => {
|
|
||||||
if (context?.previousData) {
|
|
||||||
for (const [queryKey, data] of context.previousData) {
|
|
||||||
queryClient.setQueryData(queryKey, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (error instanceof ApiError) {
|
|
||||||
const detail = (error.response as any)?.detail ?? "";
|
|
||||||
if (
|
|
||||||
typeof detail === "string" &&
|
|
||||||
detail.toLowerCase().includes("already exists")
|
|
||||||
) {
|
|
||||||
form.setError("folderName", {
|
|
||||||
message: "A folder with this name already exists",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to update folder. Please try again.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
toast({
|
|
||||||
title: "Folder updated",
|
|
||||||
description: "Your folder has been updated successfully.",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function onSubmit(values: z.infer<typeof editFolderSchema>) {
|
|
||||||
updateFolder({
|
|
||||||
folderId: folder.id,
|
|
||||||
data: {
|
|
||||||
name: values.folderName.trim(),
|
|
||||||
color: values.folderColor,
|
|
||||||
icon: values.folderIcon,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
title="Edit Folder"
|
|
||||||
styling={{ maxWidth: "30rem" }}
|
|
||||||
controlled={{
|
|
||||||
isOpen,
|
|
||||||
set: setIsOpen,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
onSubmit={(values) => onSubmit(values)}
|
|
||||||
className="flex flex-col justify-center gap-2 px-1"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="folderName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id={field.name}
|
|
||||||
label="Folder name"
|
|
||||||
placeholder="Enter folder name"
|
|
||||||
className="w-full"
|
|
||||||
wrapperClassName="!mb-0"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="folderColor"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
id="folderColor"
|
|
||||||
label="Folder color"
|
|
||||||
placeholder="Select a color"
|
|
||||||
value={field.value}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
wrapperClassName="!mb-0"
|
|
||||||
options={FOLDER_COLORS.map((color) => ({
|
|
||||||
value: color.value,
|
|
||||||
label: color.label,
|
|
||||||
icon: (
|
|
||||||
<div
|
|
||||||
className="h-4 w-4 rounded-full"
|
|
||||||
style={{ backgroundColor: color.value }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}))}
|
|
||||||
renderItem={(option) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{option.icon}
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="folderIcon"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Text variant="large-medium" as="span" className="text-black">
|
|
||||||
Folder icon
|
|
||||||
</Text>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Text variant="small" className="text-zinc-500">
|
|
||||||
Selected:
|
|
||||||
</Text>
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-2xl">
|
|
||||||
{form.watch("folderIcon") || (
|
|
||||||
<span className="text-sm text-zinc-400">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-[295px] w-full overflow-hidden">
|
|
||||||
<EmojiPicker
|
|
||||||
onEmojiSelect={(emoji) => {
|
|
||||||
field.onChange(emoji);
|
|
||||||
}}
|
|
||||||
emojiSize={32}
|
|
||||||
className="w-full rounded-2xl px-2"
|
|
||||||
>
|
|
||||||
<EmojiPicker.Group className="pt-2">
|
|
||||||
<EmojiPicker.List
|
|
||||||
hideStickyHeader
|
|
||||||
containerHeight={295}
|
|
||||||
/>
|
|
||||||
</EmojiPicker.Group>
|
|
||||||
</EmojiPicker>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormMessage />
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
className="mt-2 min-w-[18rem]"
|
|
||||||
disabled={!form.formState.isValid || isPending}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Icon } from "@phosphor-icons/react";
|
|
||||||
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
|
|
||||||
|
|
||||||
export interface Tab {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
icon: Icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tabs: Tab[];
|
|
||||||
activeTab: string;
|
|
||||||
onTabChange: (tabId: string) => void;
|
|
||||||
layoutId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibraryTabs({
|
|
||||||
tabs,
|
|
||||||
activeTab,
|
|
||||||
onTabChange,
|
|
||||||
layoutId = "library-tabs",
|
|
||||||
}: Props) {
|
|
||||||
const { registerFavoritesTabRef } = useFavoriteAnimation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TabButton
|
|
||||||
key={tab.id}
|
|
||||||
tab={tab}
|
|
||||||
isActive={activeTab === tab.id}
|
|
||||||
onSelect={onTabChange}
|
|
||||||
layoutId={layoutId}
|
|
||||||
onRefReady={
|
|
||||||
tab.id === "favorites" ? registerFavoritesTabRef : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabButtonProps {
|
|
||||||
tab: Tab;
|
|
||||||
isActive: boolean;
|
|
||||||
onSelect: (tabId: string) => void;
|
|
||||||
layoutId: string;
|
|
||||||
onRefReady?: (element: HTMLElement | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({
|
|
||||||
tab,
|
|
||||||
isActive,
|
|
||||||
onSelect,
|
|
||||||
layoutId,
|
|
||||||
onRefReady,
|
|
||||||
}: TabButtonProps) {
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
const buttonRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isActive && !isLoaded) {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
|
||||||
}, [isActive, isLoaded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (onRefReady) {
|
|
||||||
onRefReady(buttonRef.current);
|
|
||||||
}
|
|
||||||
}, [onRefReady]);
|
|
||||||
|
|
||||||
const ButtonIcon = tab.icon;
|
|
||||||
const activeColor = "text-primary";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
ref={buttonRef}
|
|
||||||
layoutId={`${layoutId}-button-${tab.id}`}
|
|
||||||
transition={{
|
|
||||||
layout: {
|
|
||||||
type: "spring",
|
|
||||||
damping: 20,
|
|
||||||
stiffness: 230,
|
|
||||||
mass: 1.2,
|
|
||||||
ease: [0.215, 0.61, 0.355, 1],
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(tab.id);
|
|
||||||
setIsLoaded(true);
|
|
||||||
}}
|
|
||||||
className="flex h-fit w-fit"
|
|
||||||
style={{ willChange: "transform" }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
layout
|
|
||||||
transition={{
|
|
||||||
layout: {
|
|
||||||
type: "spring",
|
|
||||||
damping: 20,
|
|
||||||
stiffness: 230,
|
|
||||||
mass: 1.2,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex h-fit cursor-pointer items-center gap-1.5 overflow-hidden border border-zinc-200 px-3 py-2 text-black transition-colors duration-75 ease-out hover:border-zinc-300 hover:bg-zinc-300",
|
|
||||||
isActive && activeColor,
|
|
||||||
isActive ? "px-4" : "px-3",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
borderRadius: "25px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
layoutId={`${layoutId}-icon-${tab.id}`}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<ButtonIcon size={18} />
|
|
||||||
</motion.div>
|
|
||||||
{isActive && (
|
|
||||||
<motion.div
|
|
||||||
className="flex items-center"
|
|
||||||
initial={isLoaded ? { opacity: 0, filter: "blur(4px)" } : false}
|
|
||||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
|
||||||
transition={{
|
|
||||||
duration: isLoaded ? 0.2 : 0,
|
|
||||||
ease: [0.86, 0, 0.07, 1],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
layoutId={`${layoutId}-text-${tab.id}`}
|
|
||||||
className="font-sans text-sm font-medium text-black"
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</motion.span>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Input } from "@/components/atoms/Input/Input";
|
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
import {
|
|
||||||
useGetV2ListLibraryFolders,
|
|
||||||
usePostV2BulkMoveAgents,
|
|
||||||
getGetV2ListLibraryFoldersQueryKey,
|
|
||||||
} from "@/app/api/__generated__/endpoints/folders/folders";
|
|
||||||
import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library";
|
|
||||||
import { okData } from "@/app/api/helpers";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
agentId: string;
|
|
||||||
agentName: string;
|
|
||||||
currentFolderId?: string | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MoveToFolderDialog({
|
|
||||||
agentId,
|
|
||||||
agentName,
|
|
||||||
currentFolderId,
|
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
|
||||||
}: Props) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const { data: foldersData } = useGetV2ListLibraryFolders(undefined, {
|
|
||||||
query: { select: okData },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: moveAgent, isPending } = usePostV2BulkMoveAgents({
|
|
||||||
mutation: {
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
|
||||||
});
|
|
||||||
setIsOpen(false);
|
|
||||||
setSearch("");
|
|
||||||
toast({
|
|
||||||
title: "Agent moved",
|
|
||||||
description: `"${agentName}" has been moved.`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to move agent. Please try again.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const folders = (foldersData?.folders ?? []).filter(
|
|
||||||
(f) =>
|
|
||||||
f.id !== currentFolderId &&
|
|
||||||
f.name.toLowerCase().includes(search.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleMoveToFolder(folderId: string) {
|
|
||||||
moveAgent({
|
|
||||||
data: {
|
|
||||||
agent_ids: [agentId],
|
|
||||||
folder_id: folderId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
controlled={{ isOpen, set: setIsOpen }}
|
|
||||||
styling={{ maxWidth: "28rem" }}
|
|
||||||
title="Move to folder"
|
|
||||||
onClose={() => {
|
|
||||||
setSearch("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Input
|
|
||||||
id="search-folders"
|
|
||||||
label="Search folders"
|
|
||||||
placeholder="Search folders..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="max-h-[280px] overflow-y-auto">
|
|
||||||
{folders.length === 0 ? (
|
|
||||||
<div className="flex h-20 items-center justify-center">
|
|
||||||
<Text variant="small" className="text-zinc-400">
|
|
||||||
No folders found
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<Button
|
|
||||||
key={folder.id}
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-start gap-3 px-3 py-2.5"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => handleMoveToFolder(folder.id)}
|
|
||||||
>
|
|
||||||
<span className="text-lg">{folder.icon ?? "📁"}</span>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<Text variant="small-medium">{folder.name}</Text>
|
|
||||||
<Text variant="small" className="text-zinc-400">
|
|
||||||
{folder.agent_count ?? 0}{" "}
|
|
||||||
{(folder.agent_count ?? 0) === 1 ? "agent" : "agents"}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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;
|
|
||||||
}
|
|
||||||
@@ -1,53 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect } from "react";
|
||||||
import { HeartIcon, ListIcon } from "@phosphor-icons/react";
|
import { FavoritesSection } from "./components/FavoritesSection/FavoritesSection";
|
||||||
import { LibraryActionHeader } from "./components/LibraryActionHeader/LibraryActionHeader";
|
import { LibraryActionHeader } from "./components/LibraryActionHeader/LibraryActionHeader";
|
||||||
import { LibraryAgentList } from "./components/LibraryAgentList/LibraryAgentList";
|
import { LibraryAgentList } from "./components/LibraryAgentList/LibraryAgentList";
|
||||||
import { Tab } from "./components/LibraryTabs/LibraryTabs";
|
|
||||||
import { useLibraryListPage } from "./components/useLibraryListPage";
|
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() {
|
export default function LibraryPage() {
|
||||||
const { searchTerm, setSearchTerm, librarySort, setLibrarySort } =
|
const { searchTerm, setSearchTerm, librarySort, setLibrarySort } =
|
||||||
useLibraryListPage();
|
useLibraryListPage();
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState(LIBRARY_TABS[0].id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "Library – AutoGPT Platform";
|
document.title = "Library – AutoGPT Platform";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleTabChange(tabId: string) {
|
|
||||||
setActiveTab(tabId);
|
|
||||||
setSelectedFolderId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFavoriteAnimationComplete = useCallback(() => {
|
|
||||||
setActiveTab("favorites");
|
|
||||||
setSelectedFolderId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
|
<LibraryActionHeader setSearchTerm={setSearchTerm} />
|
||||||
<LibraryActionHeader setSearchTerm={setSearchTerm} />
|
<FavoritesSection searchTerm={searchTerm} />
|
||||||
<LibraryAgentList
|
<LibraryAgentList
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
librarySort={librarySort}
|
librarySort={librarySort}
|
||||||
setLibrarySort={setLibrarySort}
|
setLibrarySort={setLibrarySort}
|
||||||
selectedFolderId={selectedFolderId}
|
/>
|
||||||
onFolderSelect={setSelectedFolderId}
|
</main>
|
||||||
tabs={LIBRARY_TABS}
|
|
||||||
activeTab={activeTab}
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
</FavoriteAnimationProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3588,29 +3588,6 @@
|
|||||||
"title": "Page Size"
|
"title": "Page Size"
|
||||||
},
|
},
|
||||||
"description": "Number of agents per page (must be >= 1)"
|
"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": {
|
"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": {
|
"/api/library/presets": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["v2", "presets"],
|
"tags": ["v2", "presets"],
|
||||||
@@ -7693,23 +7336,6 @@
|
|||||||
"required": ["file"],
|
"required": ["file"],
|
||||||
"title": "Body_postV2Upload submission media"
|
"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": {
|
"ChangelogEntry": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"version": { "type": "string", "title": "Version" },
|
"version": { "type": "string", "title": "Version" },
|
||||||
@@ -8394,96 +8020,6 @@
|
|||||||
"title": "ExecutionStartedResponse",
|
"title": "ExecutionStartedResponse",
|
||||||
"description": "Response for run/schedule actions."
|
"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": {
|
"Graph": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "title": "Id" },
|
"id": { "type": "string", "title": "Id" },
|
||||||
@@ -9372,14 +8908,6 @@
|
|||||||
"title": "Is Latest Version"
|
"title": "Is Latest Version"
|
||||||
},
|
},
|
||||||
"is_favorite": { "type": "boolean", "title": "Is Favorite" },
|
"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": {
|
"recommended_schedule_cron": {
|
||||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||||
"title": "Recommended Schedule Cron"
|
"title": "Recommended Schedule Cron"
|
||||||
@@ -9647,109 +9175,12 @@
|
|||||||
{ "type": "null" }
|
{ "type": "null" }
|
||||||
],
|
],
|
||||||
"description": "User-specific settings for this library agent"
|
"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",
|
"type": "object",
|
||||||
"title": "LibraryAgentUpdateRequest",
|
"title": "LibraryAgentUpdateRequest",
|
||||||
"description": "Schema for updating a library agent via PUT.\n\nIncludes flags for auto-updating version, marking as favorite,\narchiving, or deleting."
|
"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": {
|
"Link": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "title": "Id" },
|
"id": { "type": "string", "title": "Id" },
|
||||||
|
|||||||
@@ -180,14 +180,3 @@ body[data-google-picker-open="true"] [data-dialog-content] {
|
|||||||
z-index: 1 !important;
|
z-index: 1 !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CoPilot chat table styling — remove left/right borders, increase padding */
|
|
||||||
[data-streamdown="table-wrapper"] table {
|
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-streamdown="table-wrapper"] th,
|
|
||||||
[data-streamdown="table-wrapper"] td {
|
|
||||||
padding: 0.875rem 1rem; /* py-3.5 px-4 */
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { loadScript } from "@/services/scripts/scripts";
|
|||||||
export async function loadGoogleAPIPicker(): Promise<void> {
|
export async function loadGoogleAPIPicker(): Promise<void> {
|
||||||
validateWindow();
|
validateWindow();
|
||||||
|
|
||||||
await loadScript("https://apis.google.com/js/api.js");
|
await loadScript("https://apis.google.com/js/api.js", {
|
||||||
|
referrerPolicy: "no-referrer-when-downgrade",
|
||||||
|
});
|
||||||
|
|
||||||
const googleAPI = window.gapi;
|
const googleAPI = window.gapi;
|
||||||
if (!googleAPI) {
|
if (!googleAPI) {
|
||||||
@@ -27,7 +29,9 @@ export async function loadGoogleIdentityServices(): Promise<void> {
|
|||||||
throw new Error("Google Identity Services cannot load on server");
|
throw new Error("Google Identity Services cannot load on server");
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadScript("https://accounts.google.com/gsi/client");
|
await loadScript("https://accounts.google.com/gsi/client", {
|
||||||
|
referrerPolicy: "no-referrer-when-downgrade",
|
||||||
|
});
|
||||||
|
|
||||||
const google = window.google;
|
const google = window.google;
|
||||||
if (!google?.accounts?.oauth2) {
|
if (!google?.accounts?.oauth2) {
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ function renderMarkdown(
|
|||||||
table: ({ children, ...props }) => (
|
table: ({ children, ...props }) => (
|
||||||
<div className="my-4 overflow-x-auto">
|
<div className="my-4 overflow-x-auto">
|
||||||
<table
|
<table
|
||||||
className="min-w-full divide-y divide-gray-200 border-y border-gray-200 dark:divide-gray-700 dark:border-gray-700"
|
className="min-w-full divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-gray-700 dark:border-gray-700"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -235,7 +235,7 @@ function renderMarkdown(
|
|||||||
),
|
),
|
||||||
th: ({ children, ...props }) => (
|
th: ({ children, ...props }) => (
|
||||||
<th
|
<th
|
||||||
className="bg-gray-50 px-4 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
className="bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -243,7 +243,7 @@ function renderMarkdown(
|
|||||||
),
|
),
|
||||||
td: ({ children, ...props }) => (
|
td: ({ children, ...props }) => (
|
||||||
<td
|
<td
|
||||||
className="border-t border-gray-200 px-4 py-3.5 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
|
className="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user