mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-17 10:12:02 -05:00
Compare commits
25 Commits
otto/secrt
...
abhi/folde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
728d82739d | ||
|
|
347e450728 | ||
|
|
2a4bb22d99 | ||
|
|
ee14e54e80 | ||
|
|
d60d79efb6 | ||
|
|
0b16c4da2c | ||
|
|
0952d72fba | ||
|
|
cd2e281193 | ||
|
|
792e141183 | ||
|
|
090975374c | ||
|
|
383e60fba5 | ||
|
|
91f7c7b0db | ||
|
|
01f18acba8 | ||
|
|
09f74594ab | ||
|
|
85b24468e8 | ||
|
|
f4848a43af | ||
|
|
94bd91388f | ||
|
|
d7d571f1be | ||
|
|
784c025938 | ||
|
|
4f99f32fbf | ||
|
|
a6c2f645f1 | ||
|
|
dce3d26d0a | ||
|
|
4337b67149 | ||
|
|
62bc325d79 | ||
|
|
e02687ad09 |
@@ -5,7 +5,7 @@ import re
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
from backend.api.features.library import db as library_db
|
from backend.api.features.library import db as library_db
|
||||||
@@ -14,7 +14,6 @@ from backend.data import execution as execution_db
|
|||||||
from backend.data.execution import ExecutionStatus, GraphExecution, GraphExecutionMeta
|
from backend.data.execution import ExecutionStatus, GraphExecution, GraphExecutionMeta
|
||||||
|
|
||||||
from .base import BaseTool
|
from .base import BaseTool
|
||||||
from .execution_utils import TERMINAL_STATUSES, wait_for_execution
|
|
||||||
from .models import (
|
from .models import (
|
||||||
AgentOutputResponse,
|
AgentOutputResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
@@ -35,7 +34,6 @@ class AgentOutputInput(BaseModel):
|
|||||||
store_slug: str = ""
|
store_slug: str = ""
|
||||||
execution_id: str = ""
|
execution_id: str = ""
|
||||||
run_time: str = "latest"
|
run_time: str = "latest"
|
||||||
wait_if_running: int = Field(default=0, ge=0, le=300)
|
|
||||||
|
|
||||||
@field_validator(
|
@field_validator(
|
||||||
"agent_name",
|
"agent_name",
|
||||||
@@ -119,11 +117,6 @@ class AgentOutputTool(BaseTool):
|
|||||||
Select which run to retrieve using:
|
Select which run to retrieve using:
|
||||||
- execution_id: Specific execution ID
|
- execution_id: Specific execution ID
|
||||||
- run_time: 'latest' (default), 'yesterday', 'last week', or ISO date 'YYYY-MM-DD'
|
- run_time: 'latest' (default), 'yesterday', 'last week', or ISO date 'YYYY-MM-DD'
|
||||||
|
|
||||||
Wait for completion (optional):
|
|
||||||
- wait_if_running: Max seconds to wait if execution is still running (0-300).
|
|
||||||
If the execution is running/queued, waits up to this many seconds for it to complete.
|
|
||||||
Returns current status on timeout. If already finished, returns immediately.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -153,13 +146,6 @@ class AgentOutputTool(BaseTool):
|
|||||||
"Time filter: 'latest', 'yesterday', 'last week', or 'YYYY-MM-DD'"
|
"Time filter: 'latest', 'yesterday', 'last week', or 'YYYY-MM-DD'"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"wait_if_running": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": (
|
|
||||||
"Max seconds to wait if execution is still running (0-300). "
|
|
||||||
"If running, waits for completion. Returns current state on timeout."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [],
|
||||||
}
|
}
|
||||||
@@ -237,14 +223,10 @@ class AgentOutputTool(BaseTool):
|
|||||||
execution_id: str | None,
|
execution_id: str | None,
|
||||||
time_start: datetime | None,
|
time_start: datetime | None,
|
||||||
time_end: datetime | None,
|
time_end: datetime | None,
|
||||||
include_running: bool = False,
|
|
||||||
) -> tuple[GraphExecution | None, list[GraphExecutionMeta], str | None]:
|
) -> tuple[GraphExecution | None, list[GraphExecutionMeta], str | None]:
|
||||||
"""
|
"""
|
||||||
Fetch execution(s) based on filters.
|
Fetch execution(s) based on filters.
|
||||||
Returns (single_execution, available_executions_meta, error_message).
|
Returns (single_execution, available_executions_meta, error_message).
|
||||||
|
|
||||||
Args:
|
|
||||||
include_running: If True, also look for running/queued executions (for waiting)
|
|
||||||
"""
|
"""
|
||||||
# If specific execution_id provided, fetch it directly
|
# If specific execution_id provided, fetch it directly
|
||||||
if execution_id:
|
if execution_id:
|
||||||
@@ -257,22 +239,11 @@ class AgentOutputTool(BaseTool):
|
|||||||
return None, [], f"Execution '{execution_id}' not found"
|
return None, [], f"Execution '{execution_id}' not found"
|
||||||
return execution, [], None
|
return execution, [], None
|
||||||
|
|
||||||
# Determine which statuses to query
|
# Get completed executions with time filters
|
||||||
statuses = [ExecutionStatus.COMPLETED]
|
|
||||||
if include_running:
|
|
||||||
statuses.extend(
|
|
||||||
[
|
|
||||||
ExecutionStatus.RUNNING,
|
|
||||||
ExecutionStatus.QUEUED,
|
|
||||||
ExecutionStatus.INCOMPLETE,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get executions with time filters
|
|
||||||
executions = await execution_db.get_graph_executions(
|
executions = await execution_db.get_graph_executions(
|
||||||
graph_id=graph_id,
|
graph_id=graph_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
statuses=statuses,
|
statuses=[ExecutionStatus.COMPLETED],
|
||||||
created_time_gte=time_start,
|
created_time_gte=time_start,
|
||||||
created_time_lte=time_end,
|
created_time_lte=time_end,
|
||||||
limit=10,
|
limit=10,
|
||||||
@@ -339,28 +310,10 @@ class AgentOutputTool(BaseTool):
|
|||||||
for e in available_executions[:5]
|
for e in available_executions[:5]
|
||||||
]
|
]
|
||||||
|
|
||||||
# Build appropriate message based on execution status
|
message = f"Found execution outputs for agent '{agent.name}'"
|
||||||
if execution.status == ExecutionStatus.COMPLETED:
|
|
||||||
message = f"Found execution outputs for agent '{agent.name}'"
|
|
||||||
elif execution.status == ExecutionStatus.FAILED:
|
|
||||||
message = f"Execution for agent '{agent.name}' failed"
|
|
||||||
elif execution.status == ExecutionStatus.TERMINATED:
|
|
||||||
message = f"Execution for agent '{agent.name}' was terminated"
|
|
||||||
elif execution.status in (
|
|
||||||
ExecutionStatus.RUNNING,
|
|
||||||
ExecutionStatus.QUEUED,
|
|
||||||
ExecutionStatus.INCOMPLETE,
|
|
||||||
):
|
|
||||||
message = (
|
|
||||||
f"Execution for agent '{agent.name}' is still {execution.status.value}. "
|
|
||||||
"Results may be incomplete. Use wait_if_running to wait for completion."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
message = f"Found execution for agent '{agent.name}' (status: {execution.status.value})"
|
|
||||||
|
|
||||||
if len(available_executions) > 1:
|
if len(available_executions) > 1:
|
||||||
message += (
|
message += (
|
||||||
f" Showing latest of {len(available_executions)} matching executions."
|
f". Showing latest of {len(available_executions)} matching executions."
|
||||||
)
|
)
|
||||||
|
|
||||||
return AgentOutputResponse(
|
return AgentOutputResponse(
|
||||||
@@ -475,17 +428,13 @@ class AgentOutputTool(BaseTool):
|
|||||||
# Parse time expression
|
# Parse time expression
|
||||||
time_start, time_end = parse_time_expression(input_data.run_time)
|
time_start, time_end = parse_time_expression(input_data.run_time)
|
||||||
|
|
||||||
# Check if we should wait for running executions
|
# Fetch execution(s)
|
||||||
wait_timeout = input_data.wait_if_running
|
|
||||||
|
|
||||||
# Fetch execution(s) - include running if we're going to wait
|
|
||||||
execution, available_executions, exec_error = await self._get_execution(
|
execution, available_executions, exec_error = await self._get_execution(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
graph_id=agent.graph_id,
|
graph_id=agent.graph_id,
|
||||||
execution_id=input_data.execution_id or None,
|
execution_id=input_data.execution_id or None,
|
||||||
time_start=time_start,
|
time_start=time_start,
|
||||||
time_end=time_end,
|
time_end=time_end,
|
||||||
include_running=wait_timeout > 0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if exec_error:
|
if exec_error:
|
||||||
@@ -494,17 +443,4 @@ class AgentOutputTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If we have an execution that's still running and we should wait
|
|
||||||
if execution and wait_timeout > 0 and execution.status not in TERMINAL_STATUSES:
|
|
||||||
logger.info(
|
|
||||||
f"Execution {execution.id} is {execution.status}, "
|
|
||||||
f"waiting up to {wait_timeout}s for completion"
|
|
||||||
)
|
|
||||||
execution = await wait_for_execution(
|
|
||||||
user_id=user_id,
|
|
||||||
graph_id=agent.graph_id,
|
|
||||||
execution_id=execution.id,
|
|
||||||
timeout_seconds=wait_timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._build_response(agent, execution, available_executions, session_id)
|
return self._build_response(agent, execution, available_executions, session_id)
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
"""Shared utilities for execution waiting and status handling."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from backend.data import execution as execution_db
|
|
||||||
from backend.data.execution import (
|
|
||||||
AsyncRedisExecutionEventBus,
|
|
||||||
ExecutionStatus,
|
|
||||||
GraphExecution,
|
|
||||||
GraphExecutionEvent,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Terminal statuses that indicate execution is complete
|
|
||||||
TERMINAL_STATUSES = frozenset(
|
|
||||||
{
|
|
||||||
ExecutionStatus.COMPLETED,
|
|
||||||
ExecutionStatus.FAILED,
|
|
||||||
ExecutionStatus.TERMINATED,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_execution(
|
|
||||||
user_id: str,
|
|
||||||
graph_id: str,
|
|
||||||
execution_id: str,
|
|
||||||
timeout_seconds: int,
|
|
||||||
) -> GraphExecution | None:
|
|
||||||
"""
|
|
||||||
Wait for an execution to reach a terminal status using Redis pubsub.
|
|
||||||
|
|
||||||
Uses asyncio.wait_for to ensure timeout is respected even when no events
|
|
||||||
are received.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User ID
|
|
||||||
graph_id: Graph ID
|
|
||||||
execution_id: Execution ID to wait for
|
|
||||||
timeout_seconds: Max seconds to wait
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The execution with current status, or None if not found
|
|
||||||
"""
|
|
||||||
# First check current status - maybe it's already done
|
|
||||||
execution = await execution_db.get_graph_execution(
|
|
||||||
user_id=user_id,
|
|
||||||
execution_id=execution_id,
|
|
||||||
include_node_executions=False,
|
|
||||||
)
|
|
||||||
if not execution:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# If already in terminal state, return immediately
|
|
||||||
if execution.status in TERMINAL_STATUSES:
|
|
||||||
logger.debug(
|
|
||||||
f"Execution {execution_id} already in terminal state: {execution.status}"
|
|
||||||
)
|
|
||||||
return execution
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Waiting up to {timeout_seconds}s for execution {execution_id} "
|
|
||||||
f"(current status: {execution.status})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Subscribe to execution updates via Redis pubsub
|
|
||||||
event_bus = AsyncRedisExecutionEventBus()
|
|
||||||
channel_key = f"{user_id}/{graph_id}/{execution_id}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use wait_for to enforce timeout on the entire listen operation
|
|
||||||
result = await asyncio.wait_for(
|
|
||||||
_listen_for_terminal_status(event_bus, channel_key, user_id, execution_id),
|
|
||||||
timeout=timeout_seconds,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.info(f"Timeout waiting for execution {execution_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error waiting for execution: {e}", exc_info=True)
|
|
||||||
|
|
||||||
# Return current state on timeout/error
|
|
||||||
return await execution_db.get_graph_execution(
|
|
||||||
user_id=user_id,
|
|
||||||
execution_id=execution_id,
|
|
||||||
include_node_executions=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _listen_for_terminal_status(
|
|
||||||
event_bus: AsyncRedisExecutionEventBus,
|
|
||||||
channel_key: str,
|
|
||||||
user_id: str,
|
|
||||||
execution_id: str,
|
|
||||||
) -> GraphExecution | None:
|
|
||||||
"""
|
|
||||||
Listen for execution events until a terminal status is reached.
|
|
||||||
|
|
||||||
This is a helper that gets wrapped in asyncio.wait_for for timeout handling.
|
|
||||||
"""
|
|
||||||
async for event in event_bus.listen_events(channel_key):
|
|
||||||
# Only process GraphExecutionEvents (not NodeExecutionEvents)
|
|
||||||
if isinstance(event, GraphExecutionEvent):
|
|
||||||
logger.debug(f"Received execution update: {event.status}")
|
|
||||||
if event.status in TERMINAL_STATUSES:
|
|
||||||
# Fetch full execution with outputs
|
|
||||||
return await execution_db.get_graph_execution(
|
|
||||||
user_id=user_id,
|
|
||||||
execution_id=execution_id,
|
|
||||||
include_node_executions=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should not reach here normally (generator should yield indefinitely)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_execution_outputs(execution: GraphExecution | None) -> dict[str, Any] | None:
|
|
||||||
"""Extract outputs from an execution, or return None."""
|
|
||||||
if execution is None:
|
|
||||||
return None
|
|
||||||
return execution.outputs
|
|
||||||
@@ -192,7 +192,6 @@ class ExecutionStartedResponse(ToolResponseBase):
|
|||||||
library_agent_id: str | None = None
|
library_agent_id: str | None = None
|
||||||
library_agent_link: str | None = None
|
library_agent_link: str | None = None
|
||||||
status: str = "QUEUED"
|
status: str = "QUEUED"
|
||||||
outputs: dict[str, Any] | None = None # Populated when wait_for_result is used
|
|
||||||
|
|
||||||
|
|
||||||
# Auth/error models
|
# Auth/error models
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from backend.api.features.chat.tracking import (
|
|||||||
track_agent_scheduled,
|
track_agent_scheduled,
|
||||||
)
|
)
|
||||||
from backend.api.features.library import db as library_db
|
from backend.api.features.library import db as library_db
|
||||||
from backend.data.execution import ExecutionStatus
|
|
||||||
from backend.data.graph import GraphModel
|
from backend.data.graph import GraphModel
|
||||||
from backend.data.model import CredentialsMetaInput
|
from backend.data.model import CredentialsMetaInput
|
||||||
from backend.data.user import get_user_by_id
|
from backend.data.user import get_user_by_id
|
||||||
@@ -25,7 +24,6 @@ from backend.util.timezone_utils import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .base import BaseTool
|
from .base import BaseTool
|
||||||
from .execution_utils import get_execution_outputs, wait_for_execution
|
|
||||||
from .helpers import get_inputs_from_schema
|
from .helpers import get_inputs_from_schema
|
||||||
from .models import (
|
from .models import (
|
||||||
AgentDetails,
|
AgentDetails,
|
||||||
@@ -72,7 +70,6 @@ class RunAgentInput(BaseModel):
|
|||||||
schedule_name: str = ""
|
schedule_name: str = ""
|
||||||
cron: str = ""
|
cron: str = ""
|
||||||
timezone: str = "UTC"
|
timezone: str = "UTC"
|
||||||
wait_for_result: int = Field(default=0, ge=0, le=300)
|
|
||||||
|
|
||||||
@field_validator(
|
@field_validator(
|
||||||
"username_agent_slug",
|
"username_agent_slug",
|
||||||
@@ -154,14 +151,6 @@ class RunAgentTool(BaseTool):
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "IANA timezone for schedule (default: UTC)",
|
"description": "IANA timezone for schedule (default: UTC)",
|
||||||
},
|
},
|
||||||
"wait_for_result": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": (
|
|
||||||
"Max seconds to wait for execution to complete (0-300). "
|
|
||||||
"If >0, blocks until the execution finishes or times out. "
|
|
||||||
"Returns execution outputs when complete."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [],
|
||||||
}
|
}
|
||||||
@@ -358,7 +347,6 @@ class RunAgentTool(BaseTool):
|
|||||||
graph=graph,
|
graph=graph,
|
||||||
graph_credentials=graph_credentials,
|
graph_credentials=graph_credentials,
|
||||||
inputs=params.inputs,
|
inputs=params.inputs,
|
||||||
wait_for_result=params.wait_for_result,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except NotFoundError as e:
|
except NotFoundError as e:
|
||||||
@@ -442,9 +430,8 @@ class RunAgentTool(BaseTool):
|
|||||||
graph: GraphModel,
|
graph: GraphModel,
|
||||||
graph_credentials: dict[str, CredentialsMetaInput],
|
graph_credentials: dict[str, CredentialsMetaInput],
|
||||||
inputs: dict[str, Any],
|
inputs: dict[str, Any],
|
||||||
wait_for_result: int = 0,
|
|
||||||
) -> ToolResponseBase:
|
) -> ToolResponseBase:
|
||||||
"""Execute an agent immediately, optionally waiting for completion."""
|
"""Execute an agent immediately."""
|
||||||
session_id = session.session_id
|
session_id = session.session_id
|
||||||
|
|
||||||
# Check rate limits
|
# Check rate limits
|
||||||
@@ -481,60 +468,6 @@ class RunAgentTool(BaseTool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
library_agent_link = f"/library/agents/{library_agent.id}"
|
library_agent_link = f"/library/agents/{library_agent.id}"
|
||||||
|
|
||||||
# If wait_for_result is specified, wait for execution to complete
|
|
||||||
if wait_for_result > 0:
|
|
||||||
logger.info(
|
|
||||||
f"Waiting up to {wait_for_result}s for execution {execution.id}"
|
|
||||||
)
|
|
||||||
result = await wait_for_execution(
|
|
||||||
user_id=user_id,
|
|
||||||
graph_id=library_agent.graph_id,
|
|
||||||
execution_id=execution.id,
|
|
||||||
timeout_seconds=wait_for_result,
|
|
||||||
)
|
|
||||||
final_status = result.status if result else ExecutionStatus.FAILED
|
|
||||||
outputs = get_execution_outputs(result)
|
|
||||||
|
|
||||||
# Build message based on final status
|
|
||||||
if final_status == ExecutionStatus.COMPLETED:
|
|
||||||
message = (
|
|
||||||
f"Agent '{library_agent.name}' execution completed successfully. "
|
|
||||||
f"{MSG_DO_NOT_RUN_AGAIN}"
|
|
||||||
)
|
|
||||||
elif final_status == ExecutionStatus.FAILED:
|
|
||||||
message = (
|
|
||||||
f"Agent '{library_agent.name}' execution failed. "
|
|
||||||
f"View details at {library_agent_link}. "
|
|
||||||
f"{MSG_DO_NOT_RUN_AGAIN}"
|
|
||||||
)
|
|
||||||
elif final_status == ExecutionStatus.TERMINATED:
|
|
||||||
message = (
|
|
||||||
f"Agent '{library_agent.name}' execution was terminated. "
|
|
||||||
f"View details at {library_agent_link}. "
|
|
||||||
f"{MSG_DO_NOT_RUN_AGAIN}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
message = (
|
|
||||||
f"Agent '{library_agent.name}' execution is still {final_status.value} "
|
|
||||||
f"(timed out after {wait_for_result}s). "
|
|
||||||
f"View at {library_agent_link}. "
|
|
||||||
f"{MSG_DO_NOT_RUN_AGAIN}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return ExecutionStartedResponse(
|
|
||||||
message=message,
|
|
||||||
session_id=session_id,
|
|
||||||
execution_id=execution.id,
|
|
||||||
graph_id=library_agent.graph_id,
|
|
||||||
graph_name=library_agent.name,
|
|
||||||
library_agent_id=library_agent.id,
|
|
||||||
library_agent_link=library_agent_link,
|
|
||||||
status=final_status.value,
|
|
||||||
outputs=outputs,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Default: return immediately without waiting
|
|
||||||
return ExecutionStartedResponse(
|
return ExecutionStartedResponse(
|
||||||
message=(
|
message=(
|
||||||
f"Agent '{library_agent.name}' execution started successfully. "
|
f"Agent '{library_agent.name}' execution started successfully. "
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
class FolderValidationError(Exception):
|
||||||
|
"""Raised when folder operations fail validation."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FolderAlreadyExistsError(FolderValidationError):
|
||||||
|
"""Raised when a folder with the same name already exists in the location."""
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -26,6 +26,95 @@ 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."""
|
||||||
|
|
||||||
@@ -120,6 +209,9 @@ 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
|
||||||
@@ -259,6 +351,8 @@ 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=agent.folderId,
|
||||||
|
folder_name=agent.Folder.name if agent.Folder else None,
|
||||||
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,
|
||||||
@@ -470,3 +564,7 @@ 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 (None to move to root)",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
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,6 +41,14 @@ 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).
|
||||||
@@ -51,6 +59,8 @@ async def list_library_agents(
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +178,7 @@ async def update_library_agent(
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import autogpt_libs.auth as autogpt_auth_lib
|
||||||
|
from fastapi import APIRouter, Query, Security, status
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from .. import db as library_db
|
||||||
|
from .. import model as library_model
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
tree = await library_db.get_folder_tree(user_id=user_id)
|
||||||
|
return library_model.FolderTreeResponse(tree=tree)
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
return await library_db.get_folder(folder_id=folder_id, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
return await library_db.create_folder(
|
||||||
|
user_id=user_id,
|
||||||
|
name=payload.name,
|
||||||
|
parent_id=payload.parent_id,
|
||||||
|
icon=payload.icon,
|
||||||
|
color=payload.color,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
return await library_db.update_folder(
|
||||||
|
folder_id=folder_id,
|
||||||
|
user_id=user_id,
|
||||||
|
name=payload.name,
|
||||||
|
icon=payload.icon,
|
||||||
|
color=payload.color,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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)"},
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
return await library_db.move_folder(
|
||||||
|
folder_id=folder_id,
|
||||||
|
user_id=user_id,
|
||||||
|
target_parent_id=payload.target_parent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# === 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.
|
||||||
|
"""
|
||||||
|
return await library_db.bulk_move_agents_to_folder(
|
||||||
|
agent_ids=payload.agent_ids,
|
||||||
|
folder_id=payload.folder_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
@@ -115,6 +115,8 @@ async def test_get_library_agents_success(
|
|||||||
sort_by=library_model.LibraryAgentSort.UPDATED_AT,
|
sort_by=library_model.LibraryAgentSort.UPDATED_AT,
|
||||||
page=1,
|
page=1,
|
||||||
page_size=15,
|
page_size=15,
|
||||||
|
folder_id=None,
|
||||||
|
include_root_only=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ from backend.api.features.chat.completion_consumer import (
|
|||||||
start_completion_consumer,
|
start_completion_consumer,
|
||||||
stop_completion_consumer,
|
stop_completion_consumer,
|
||||||
)
|
)
|
||||||
|
from backend.api.features.library.exceptions import (
|
||||||
|
FolderAlreadyExistsError,
|
||||||
|
FolderValidationError,
|
||||||
|
)
|
||||||
from backend.blocks.llm import DEFAULT_LLM_MODEL
|
from backend.blocks.llm import DEFAULT_LLM_MODEL
|
||||||
from backend.data.model import Credentials
|
from backend.data.model import Credentials
|
||||||
from backend.integrations.providers import ProviderName
|
from backend.integrations.providers import ProviderName
|
||||||
@@ -277,6 +281,10 @@ async def validation_error_handler(
|
|||||||
|
|
||||||
|
|
||||||
app.add_exception_handler(PrismaError, handle_internal_http_error(500))
|
app.add_exception_handler(PrismaError, handle_internal_http_error(500))
|
||||||
|
app.add_exception_handler(
|
||||||
|
FolderAlreadyExistsError, handle_internal_http_error(409, False)
|
||||||
|
)
|
||||||
|
app.add_exception_handler(FolderValidationError, handle_internal_http_error(400, False))
|
||||||
app.add_exception_handler(NotFoundError, handle_internal_http_error(404, False))
|
app.add_exception_handler(NotFoundError, handle_internal_http_error(404, False))
|
||||||
app.add_exception_handler(NotAuthorizedError, handle_internal_http_error(403, False))
|
app.add_exception_handler(NotAuthorizedError, handle_internal_http_error(403, False))
|
||||||
app.add_exception_handler(RequestValidationError, validation_error_handler)
|
app.add_exception_handler(RequestValidationError, validation_error_handler)
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LibraryAgent" ADD COLUMN "folderId" TEXT;
|
||||||
|
|
||||||
|
-- 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 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,6 +51,7 @@ model User {
|
|||||||
ChatSessions ChatSession[]
|
ChatSessions ChatSession[]
|
||||||
AgentPresets AgentPreset[]
|
AgentPresets AgentPreset[]
|
||||||
LibraryAgents LibraryAgent[]
|
LibraryAgents LibraryAgent[]
|
||||||
|
LibraryFolders LibraryFolder[]
|
||||||
|
|
||||||
Profile Profile[]
|
Profile Profile[]
|
||||||
UserOnboarding UserOnboarding?
|
UserOnboarding UserOnboarding?
|
||||||
@@ -395,6 +396,9 @@ 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)
|
||||||
@@ -407,6 +411,30 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
"can_access_graph": true,
|
"can_access_graph": true,
|
||||||
"is_latest_version": true,
|
"is_latest_version": true,
|
||||||
"is_favorite": false,
|
"is_favorite": false,
|
||||||
|
"folder_id": null,
|
||||||
|
"folder_name": null,
|
||||||
"recommended_schedule_cron": null,
|
"recommended_schedule_cron": null,
|
||||||
"settings": {
|
"settings": {
|
||||||
"human_in_the_loop_safe_mode": true,
|
"human_in_the_loop_safe_mode": true,
|
||||||
@@ -83,6 +85,8 @@
|
|||||||
"can_access_graph": false,
|
"can_access_graph": false,
|
||||||
"is_latest_version": true,
|
"is_latest_version": true,
|
||||||
"is_favorite": false,
|
"is_favorite": false,
|
||||||
|
"folder_id": null,
|
||||||
|
"folder_name": null,
|
||||||
"recommended_schedule_cron": null,
|
"recommended_schedule_cron": null,
|
||||||
"settings": {
|
"settings": {
|
||||||
"human_in_the_loop_safe_mode": true,
|
"human_in_the_loop_safe_mode": true,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"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,6 +18,9 @@ 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))
|
||||||
@@ -1507,6 +1510,14 @@ 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==}
|
||||||
|
|
||||||
@@ -3114,6 +3125,10 @@ 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==}
|
||||||
|
|
||||||
@@ -3381,10 +3396,19 @@ 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'}
|
||||||
@@ -4378,6 +4402,10 @@ 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==}
|
||||||
|
|
||||||
@@ -4995,6 +5023,9 @@ 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'}
|
||||||
@@ -5975,6 +6006,24 @@ 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==}
|
||||||
|
|
||||||
@@ -6593,6 +6642,10 @@ 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}
|
||||||
@@ -7691,6 +7744,10 @@ 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'}
|
||||||
@@ -8168,6 +8225,13 @@ 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'}
|
||||||
@@ -9777,6 +9841,22 @@ 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
|
||||||
@@ -11538,6 +11618,8 @@ 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': {}
|
||||||
@@ -12011,8 +12093,16 @@ 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
|
||||||
@@ -13104,6 +13194,8 @@ 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: {}
|
||||||
@@ -13747,6 +13839,8 @@ 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:
|
||||||
@@ -15028,6 +15122,13 @@ 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:
|
||||||
@@ -15896,6 +15997,13 @@ 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
|
||||||
@@ -17196,6 +17304,10 @@ 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):
|
||||||
@@ -17711,6 +17823,10 @@ 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
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
"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 }: Props) {
|
export function FavoritesSection({
|
||||||
|
searchTerm,
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
setLibrarySort,
|
||||||
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
allAgents: favoriteAgents,
|
allAgents: favoriteAgents,
|
||||||
agentLoading: isLoading,
|
agentLoading: isLoading,
|
||||||
@@ -21,38 +35,33 @@ export function FavoritesSection({ searchTerm }: Props) {
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useFavoriteAgents({ searchTerm });
|
} = useFavoriteAgents({ searchTerm });
|
||||||
|
|
||||||
if (isLoading || favoriteAgents.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="!mb-8">
|
<>
|
||||||
<div className="mb-3 flex items-center gap-2 p-2">
|
<LibraryActionSubHeader
|
||||||
<HeartIcon className="h-5 w-5" weight="fill" />
|
agentCount={agentCount}
|
||||||
<div className="flex items-baseline gap-2">
|
setLibrarySort={setLibrarySort}
|
||||||
<Text variant="h4">Favorites</Text>
|
/>
|
||||||
{!isLoading && (
|
<LibraryTabs
|
||||||
<Text
|
tabs={tabs}
|
||||||
variant="body"
|
activeTab={activeTab}
|
||||||
data-testid="agents-count"
|
onTabChange={onTabChange}
|
||||||
className="relative bottom-px text-zinc-500"
|
/>
|
||||||
>
|
|
||||||
{agentCount}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
{isLoading ? (
|
||||||
|
<div className="flex h-[200px] items-center justify-center">
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
</div>
|
||||||
|
) : favoriteAgents.length === 0 ? (
|
||||||
|
<div className="flex h-[200px] flex-col items-center justify-center gap-2 text-zinc-500">
|
||||||
|
<HeartIcon className="h-10 w-10" />
|
||||||
|
<Text variant="body">No favorite agents yet</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
fetchNextPage={fetchNextPage}
|
fetchNextPage={fetchNextPage}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
loader={
|
loader={<LoadingSpinner size="medium" />}
|
||||||
<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) => (
|
||||||
@@ -60,9 +69,7 @@ export function FavoritesSection({ searchTerm }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
</div>
|
)}
|
||||||
|
</>
|
||||||
{favoriteAgents.length > 0 && <div className="!mt-10 border-t" />}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"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="h4">My agents</Text>
|
<Text variant="h5">My agents</Text>
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
data-testid="agents-count"
|
data-testid="agents-count"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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, {
|
||||||
@@ -14,13 +15,21 @@ 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({ agent }: Props) {
|
export function LibraryAgentCard({ 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,
|
||||||
@@ -28,103 +37,119 @@ export function LibraryAgentCard({ agent }: Props) {
|
|||||||
profile,
|
profile,
|
||||||
creator_image_url,
|
creator_image_url,
|
||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
} = useLibraryAgentCard({ agent });
|
} = useLibraryAgentCard({
|
||||||
|
agent,
|
||||||
|
onFavoriteAdd: triggerFavoriteAnimation,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="library-agent-card"
|
draggable={draggable}
|
||||||
data-agent-id={id}
|
onDragStart={handleDragStart}
|
||||||
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"
|
className="cursor-grab active:cursor-grabbing"
|
||||||
>
|
>
|
||||||
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
|
<motion.div
|
||||||
<div className="relative flex items-center gap-2 px-4 pt-3">
|
layoutId={`agent-card-${id}`}
|
||||||
<Avatar className="h-4 w-4 rounded-full">
|
data-testid="library-agent-card"
|
||||||
<AvatarImage
|
data-agent-id={id}
|
||||||
src={
|
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"
|
||||||
isFromMarketplace
|
transition={{
|
||||||
? creator_image_url || "/avatar-placeholder.png"
|
type: "spring",
|
||||||
: profile?.avatar_url || "/avatar-placeholder.png"
|
damping: 25,
|
||||||
}
|
stiffness: 300,
|
||||||
alt={`${name} creator avatar`}
|
}}
|
||||||
/>
|
style={{ willChange: "transform" }}
|
||||||
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
|
>
|
||||||
</Avatar>
|
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
|
||||||
<Text
|
<div className="relative flex items-center gap-2 px-4 pt-3">
|
||||||
variant="small-medium"
|
<Avatar className="h-4 w-4 rounded-full">
|
||||||
className="uppercase tracking-wide text-zinc-400"
|
<AvatarImage
|
||||||
>
|
src={
|
||||||
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
|
isFromMarketplace
|
||||||
</Text>
|
? creator_image_url || "/avatar-placeholder.png"
|
||||||
</div>
|
: profile?.avatar_url || "/avatar-placeholder.png"
|
||||||
</NextLink>
|
}
|
||||||
<FavoriteButton
|
alt={`${name} creator avatar`}
|
||||||
isFavorite={isFavorite}
|
/>
|
||||||
onClick={handleToggleFavorite}
|
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
|
||||||
className="absolute right-10 top-0"
|
</Avatar>
|
||||||
/>
|
<Text
|
||||||
<AgentCardMenu agent={agent} />
|
variant="small-medium"
|
||||||
|
className="uppercase tracking-wide text-zinc-400"
|
||||||
|
>
|
||||||
|
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</NextLink>
|
||||||
|
<FavoriteButton
|
||||||
|
isFavorite={isFavorite}
|
||||||
|
onClick={handleToggleFavorite}
|
||||||
|
className="absolute right-10 top-0"
|
||||||
|
/>
|
||||||
|
<AgentCardMenu agent={agent} />
|
||||||
|
|
||||||
<div className="flex w-full flex-1 flex-col px-4 pb-2">
|
<div className="flex w-full flex-1 flex-col px-4 pb-2">
|
||||||
<Link
|
|
||||||
href={`/library/agents/${id}`}
|
|
||||||
className="flex w-full items-start justify-between gap-2 no-underline hover:no-underline"
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
variant="h5"
|
|
||||||
data-testid="library-agent-card-name"
|
|
||||||
className="line-clamp-3 hyphens-auto break-words no-underline hover:no-underline"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{!image_url ? (
|
|
||||||
<div
|
|
||||||
className={`h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small ${
|
|
||||||
[
|
|
||||||
"bg-gradient-to-r from-green-200 to-blue-200",
|
|
||||||
"bg-gradient-to-r from-pink-200 to-purple-200",
|
|
||||||
"bg-gradient-to-r from-yellow-200 to-orange-200",
|
|
||||||
"bg-gradient-to-r from-blue-200 to-cyan-200",
|
|
||||||
"bg-gradient-to-r from-indigo-200 to-purple-200",
|
|
||||||
][parseInt(id.slice(0, 8), 16) % 5]
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundSize: "200% 200%",
|
|
||||||
animation: "gradient 15s ease infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Image
|
|
||||||
src={image_url}
|
|
||||||
alt={`${name} preview image`}
|
|
||||||
width={107}
|
|
||||||
height={58}
|
|
||||||
className="flex-shrink-0 rounded-small object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
|
|
||||||
<Link
|
<Link
|
||||||
href={`/library/agents/${id}`}
|
href={`/library/agents/${id}`}
|
||||||
data-testid="library-agent-card-see-runs-link"
|
className="flex w-full items-start justify-between gap-2 no-underline hover:no-underline"
|
||||||
className="flex items-center gap-1 text-[13px]"
|
|
||||||
>
|
>
|
||||||
See runs <CaretCircleRightIcon size={20} />
|
<Text
|
||||||
|
variant="h5"
|
||||||
|
data-testid="library-agent-card-name"
|
||||||
|
className="line-clamp-3 hyphens-auto break-words no-underline hover:no-underline"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!image_url ? (
|
||||||
|
<div
|
||||||
|
className={`h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small ${
|
||||||
|
[
|
||||||
|
"bg-gradient-to-r from-green-200 to-blue-200",
|
||||||
|
"bg-gradient-to-r from-pink-200 to-purple-200",
|
||||||
|
"bg-gradient-to-r from-yellow-200 to-orange-200",
|
||||||
|
"bg-gradient-to-r from-blue-200 to-cyan-200",
|
||||||
|
"bg-gradient-to-r from-indigo-200 to-purple-200",
|
||||||
|
][parseInt(id.slice(0, 8), 16) % 5]
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundSize: "200% 200%",
|
||||||
|
animation: "gradient 15s ease infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={image_url}
|
||||||
|
alt={`${name} preview image`}
|
||||||
|
width={107}
|
||||||
|
height={58}
|
||||||
|
className="flex-shrink-0 rounded-small object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{can_access_graph && (
|
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
|
||||||
<Link
|
<Link
|
||||||
href={`/build?flowID=${graph_id}`}
|
href={`/library/agents/${id}`}
|
||||||
data-testid="library-agent-card-open-in-builder-link"
|
data-testid="library-agent-card-see-runs-link"
|
||||||
className="flex items-center gap-1 text-[13px]"
|
className="flex items-center gap-1 text-[13px]"
|
||||||
isExternal
|
|
||||||
>
|
>
|
||||||
Open in builder <CaretCircleRightIcon size={20} />
|
See runs <CaretCircleRightIcon size={20} />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
|
{can_access_graph && (
|
||||||
|
<Link
|
||||||
|
href={`/build?flowID=${graph_id}`}
|
||||||
|
data-testid="library-agent-card-open-in-builder-link"
|
||||||
|
className="flex items-center gap-1 text-[13px]"
|
||||||
|
isExternal
|
||||||
|
>
|
||||||
|
Open in builder <CaretCircleRightIcon size={20} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ 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";
|
||||||
@@ -22,6 +26,7 @@ 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;
|
||||||
@@ -32,11 +37,25 @@ 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;
|
||||||
@@ -70,6 +89,37 @@ 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;
|
||||||
|
|
||||||
@@ -138,6 +188,31 @@ 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();
|
||||||
@@ -183,6 +258,14 @@ 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,10 +3,15 @@
|
|||||||
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>) => void;
|
onClick: (
|
||||||
|
e: MouseEvent<HTMLButtonElement>,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,25 +20,49 @@ 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
|
||||||
onClick={onClick}
|
ref={buttonRef}
|
||||||
|
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",
|
"hover:scale-110 active:scale-95",
|
||||||
!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"}
|
||||||
>
|
>
|
||||||
<HeartIcon
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
size={20}
|
<motion.div
|
||||||
weight={isFavorite ? "fill" : "regular"}
|
key={isFavorite ? "filled" : "empty"}
|
||||||
className={cn(
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
"transition-colors duration-200",
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
|
exit={{ scale: 0.5, opacity: 0 }}
|
||||||
)}
|
transition={{ type: "spring", damping: 15, stiffness: 300 }}
|
||||||
/>
|
>
|
||||||
|
<HeartIcon
|
||||||
|
size={20}
|
||||||
|
weight={isFavorite ? "fill" : "regular"}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors duration-200",
|
||||||
|
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
</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 }: Props) {
|
export function useLibraryAgentCard({ agent, onFavoriteAdd }: Props) {
|
||||||
const { id, name, is_favorite, creator_image_url, marketplace_listing } =
|
const { id, is_favorite, creator_image_url, marketplace_listing } = agent;
|
||||||
agent;
|
|
||||||
|
|
||||||
const isFromMarketplace = Boolean(marketplace_listing);
|
const isFromMarketplace = Boolean(marketplace_listing);
|
||||||
const [isFavorite, setIsFavorite] = useState(is_favorite);
|
const [isFavorite, setIsFavorite] = useState(is_favorite);
|
||||||
@@ -49,26 +49,31 @@ export function useLibraryAgentCard({ agent }: Props) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleFavorite(e: React.MouseEvent) {
|
async function handleToggleFavorite(
|
||||||
|
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,30 +1,130 @@
|
|||||||
"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,
|
||||||
allAgents: agents,
|
agents,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
} = useLibraryAgentList({ searchTerm, librarySort });
|
foldersData,
|
||||||
|
currentFolder,
|
||||||
|
showFolders,
|
||||||
|
editingFolder,
|
||||||
|
setEditingFolder,
|
||||||
|
deletingFolder,
|
||||||
|
setDeletingFolder,
|
||||||
|
handleAgentDrop,
|
||||||
|
handleFolderDeleted,
|
||||||
|
} = useLibraryAgentList({
|
||||||
|
searchTerm,
|
||||||
|
librarySort,
|
||||||
|
selectedFolderId,
|
||||||
|
onFolderSelect,
|
||||||
|
activeTab,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -32,11 +132,42 @@ export function LibraryAgentList({
|
|||||||
agentCount={agentCount}
|
agentCount={agentCount}
|
||||||
setLibrarySort={setLibrarySort}
|
setLibrarySort={setLibrarySort}
|
||||||
/>
|
/>
|
||||||
<div className="px-2">
|
{!selectedFolderId && (
|
||||||
|
<LibrarySubSection
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{selectedFolderId && (
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onFolderSelect(null)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
Back to Library
|
||||||
|
</Button>
|
||||||
|
{currentFolder && (
|
||||||
|
<Text variant="h4" className="text-zinc-700">
|
||||||
|
{currentFolder.icon} {currentFolder.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{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}
|
||||||
@@ -44,14 +175,85 @@ export function LibraryAgentList({
|
|||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
loader={<LoadingSpinner size="medium" />}
|
loader={<LoadingSpinner size="medium" />}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<LayoutGroup>
|
||||||
{agents.map((agent) => (
|
<AnimatePresence mode="popLayout">
|
||||||
<LibraryAgentCard key={agent.id} agent={agent} />
|
<motion.div
|
||||||
))}
|
key={`${activeTab}-${selectedFolderId || "all"}`}
|
||||||
</div>
|
className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
variants={activeContainerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
exit="exit"
|
||||||
|
>
|
||||||
|
{showFolders &&
|
||||||
|
foldersData?.folders.map((folder, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={folder.id}
|
||||||
|
initial={activeInitial}
|
||||||
|
animate={activeAnimate}
|
||||||
|
transition={{
|
||||||
|
...activeTransition,
|
||||||
|
delay: i * 0.04,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LibraryFolder
|
||||||
|
id={folder.id}
|
||||||
|
name={folder.name}
|
||||||
|
agentCount={folder.agent_count ?? 0}
|
||||||
|
color={folder.color ?? undefined}
|
||||||
|
icon={folder.icon ?? "📁"}
|
||||||
|
onAgentDrop={handleAgentDrop}
|
||||||
|
onClick={() => onFolderSelect(folder.id)}
|
||||||
|
onEdit={() => setEditingFolder(folder)}
|
||||||
|
onDelete={() => setDeletingFolder(folder)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
{agents.map((agent, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={agent.id}
|
||||||
|
initial={activeInitial}
|
||||||
|
animate={activeAnimate}
|
||||||
|
transition={{
|
||||||
|
...activeTransition,
|
||||||
|
delay:
|
||||||
|
((showFolders
|
||||||
|
? (foldersData?.folders.length ?? 0)
|
||||||
|
: 0) +
|
||||||
|
i) *
|
||||||
|
0.04,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LibraryAgentCard agent={agent} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</LayoutGroup>
|
||||||
</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,36 +1,69 @@
|
|||||||
"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 { useEffect, useRef } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
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({ searchTerm, librarySort }: Props) {
|
export function useLibraryAgentList({
|
||||||
const queryClient = getQueryClient();
|
searchTerm,
|
||||||
|
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: agentLoading,
|
isLoading: allAgentsLoading,
|
||||||
} = 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: {
|
||||||
@@ -39,28 +72,147 @@ export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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) {
|
||||||
// Reset all library agent queries to ensure fresh fetch with new sort
|
stableQueryClient.resetQueries({
|
||||||
queryClient.resetQueries({
|
|
||||||
queryKey: ["/api/library/agents"],
|
queryKey: ["/api/library/agents"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
prevSortRef.current = librarySort;
|
prevSortRef.current = librarySort;
|
||||||
}, [librarySort, queryClient]);
|
}, [librarySort, stableQueryClient]);
|
||||||
|
|
||||||
const allAgents = agentsQueryData
|
const allAgentsList = agentsQueryData
|
||||||
? unpaginate(agentsQueryData, "agents")
|
? unpaginate(agentsQueryData, "agents")
|
||||||
: [];
|
: [];
|
||||||
const agentCount = getPaginatedTotalCount(agentsQueryData);
|
const allAgentsCount = getPaginatedTotalCount(agentsQueryData);
|
||||||
|
|
||||||
|
const favoriteAgentsData = useFavoriteAgents({ searchTerm });
|
||||||
|
|
||||||
|
const {
|
||||||
|
agentLoading,
|
||||||
|
agentCount,
|
||||||
|
allAgents: agents,
|
||||||
|
hasNextPage: agentsHasNextPage,
|
||||||
|
isFetchingNextPage: agentsIsFetchingNextPage,
|
||||||
|
fetchNextPage: agentsFetchNextPage,
|
||||||
|
} = isFavoritesTab
|
||||||
|
? favoriteAgentsData
|
||||||
|
: {
|
||||||
|
agentLoading: allAgentsLoading,
|
||||||
|
agentCount: allAgentsCount,
|
||||||
|
allAgents: allAgentsList,
|
||||||
|
hasNextPage: hasNextPage,
|
||||||
|
isFetchingNextPage: isFetchingNextPage,
|
||||||
|
fetchNextPage: fetchNextPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: rawFoldersData } = useGetV2ListLibraryFolders(undefined, {
|
||||||
|
query: { select: okData },
|
||||||
|
});
|
||||||
|
|
||||||
|
const foldersData = searchTerm ? undefined : rawFoldersData;
|
||||||
|
|
||||||
|
const { mutate: moveAgentToFolder } = usePostV2BulkMoveAgents({
|
||||||
|
mutation: {
|
||||||
|
onMutate: async ({ data }) => {
|
||||||
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||||
|
});
|
||||||
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousFolders =
|
||||||
|
queryClient.getQueriesData<getV2ListLibraryFoldersResponseSuccess>({
|
||||||
|
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.folder_id) {
|
||||||
|
queryClient.setQueriesData<getV2ListLibraryFoldersResponseSuccess>(
|
||||||
|
{ queryKey: getGetV2ListLibraryFoldersQueryKey() },
|
||||||
|
(old) => {
|
||||||
|
if (!old?.data?.folders) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
data: {
|
||||||
|
...old.data,
|
||||||
|
folders: old.data.folders.map((f) =>
|
||||||
|
f.id === data.folder_id
|
||||||
|
? {
|
||||||
|
...f,
|
||||||
|
agent_count:
|
||||||
|
(f.agent_count ?? 0) + data.agent_ids.length,
|
||||||
|
}
|
||||||
|
: f,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousFolders };
|
||||||
|
},
|
||||||
|
onError: (_error, _variables, context) => {
|
||||||
|
if (context?.previousFolders) {
|
||||||
|
for (const [queryKey, data] of context.previousFolders) {
|
||||||
|
queryClient.setQueryData(queryKey, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to move agent. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleAgentDrop(agentId: string, folderId: string) {
|
||||||
|
moveAgentToFolder({
|
||||||
|
data: {
|
||||||
|
agent_ids: [agentId],
|
||||||
|
folder_id: folderId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFolder = selectedFolderId
|
||||||
|
? foldersData?.folders.find((f) => f.id === selectedFolderId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const showFolders = !isFavoritesTab && !selectedFolderId;
|
||||||
|
|
||||||
|
function handleFolderDeleted() {
|
||||||
|
if (selectedFolderId === deletingFolder?.id) {
|
||||||
|
onFolderSelect(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allAgents,
|
isFavoritesTab,
|
||||||
agentLoading,
|
agentLoading,
|
||||||
hasNextPage,
|
|
||||||
agentCount,
|
agentCount,
|
||||||
isFetchingNextPage,
|
agents,
|
||||||
fetchNextPage,
|
hasNextPage: agentsHasNextPage,
|
||||||
|
isFetchingNextPage: agentsIsFetchingNextPage,
|
||||||
|
fetchNextPage: agentsFetchNextPage,
|
||||||
|
foldersData,
|
||||||
|
currentFolder,
|
||||||
|
showFolders,
|
||||||
|
editingFolder,
|
||||||
|
setEditingFolder,
|
||||||
|
deletingFolder,
|
||||||
|
setDeletingFolder,
|
||||||
|
handleAgentDrop,
|
||||||
|
handleFolderDeleted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { Input } from "@/components/atoms/Input/Input";
|
||||||
|
import { Select } from "@/components/atoms/Select/Select";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/molecules/Form/Form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { FolderSimpleIcon } from "@phosphor-icons/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { EmojiPicker } from "@ferrucc-io/emoji-picker";
|
||||||
|
import {
|
||||||
|
usePostV2CreateFolder,
|
||||||
|
getGetV2ListLibraryFoldersQueryKey,
|
||||||
|
} from "@/app/api/__generated__/endpoints/folders/folders";
|
||||||
|
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const FOLDER_COLORS = [
|
||||||
|
{ value: "#3B82F6", label: "Blue" },
|
||||||
|
{ value: "#A855F7", label: "Purple" },
|
||||||
|
{ value: "#10B981", label: "Green" },
|
||||||
|
{ value: "#F97316", label: "Orange" },
|
||||||
|
{ value: "#EC4899", label: "Pink" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const libraryFolderCreationFormSchema = z.object({
|
||||||
|
folderName: z.string().min(1, "Folder name is required"),
|
||||||
|
folderColor: z.string().min(1, "Folder color is required"),
|
||||||
|
folderIcon: z.string().min(1, "Folder icon is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function LibraryFolderCreationDialog() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutate: createFolder, isPending } = usePostV2CreateFolder({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getGetV2ListLibraryFoldersQueryKey(),
|
||||||
|
});
|
||||||
|
setIsOpen(false);
|
||||||
|
form.reset();
|
||||||
|
toast({
|
||||||
|
title: "Folder created",
|
||||||
|
description: "Your folder has been created successfully.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to create folder. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof libraryFolderCreationFormSchema>>({
|
||||||
|
resolver: zodResolver(libraryFolderCreationFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
folderName: "",
|
||||||
|
folderColor: "",
|
||||||
|
folderIcon: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof libraryFolderCreationFormSchema>) {
|
||||||
|
createFolder({
|
||||||
|
data: {
|
||||||
|
name: values.folderName.trim(),
|
||||||
|
color: values.folderColor,
|
||||||
|
icon: values.folderIcon,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title="Create Folder"
|
||||||
|
styling={{ maxWidth: "30rem" }}
|
||||||
|
controlled={{
|
||||||
|
isOpen,
|
||||||
|
set: setIsOpen,
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Trigger>
|
||||||
|
<Button
|
||||||
|
data-testid="upload-agent-button"
|
||||||
|
variant="secondary"
|
||||||
|
className="h-fit w-fit"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<FolderSimpleIcon width={18} height={18} />
|
||||||
|
<span className="create-folder">Create folder</span>
|
||||||
|
</Button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onSubmit={(values) => onSubmit(values)}
|
||||||
|
className="flex flex-col justify-center gap-2 px-1"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="folderName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id={field.name}
|
||||||
|
label="Folder name"
|
||||||
|
placeholder="Enter folder name"
|
||||||
|
className="!mb-0 w-full"
|
||||||
|
wrapperClassName="!mb-0"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="folderColor"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
id="folderColor"
|
||||||
|
label="Folder color"
|
||||||
|
placeholder="Select a color"
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
options={FOLDER_COLORS.map((color) => ({
|
||||||
|
value: color.value,
|
||||||
|
label: color.label,
|
||||||
|
icon: (
|
||||||
|
<div
|
||||||
|
className="h-4 w-4 rounded-full"
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
wrapperClassName="!mb-0"
|
||||||
|
renderItem={(option) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{option.icon}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="folderIcon"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Text variant="large-medium" as="span" className="text-black">
|
||||||
|
Folder icon
|
||||||
|
</Text>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Text variant="small" className="text-zinc-500">
|
||||||
|
Selected:
|
||||||
|
</Text>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-2xl">
|
||||||
|
{form.watch("folderIcon") || (
|
||||||
|
<span className="text-sm text-zinc-400">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-[295px] w-full overflow-hidden">
|
||||||
|
<EmojiPicker
|
||||||
|
onEmojiSelect={(emoji) => {
|
||||||
|
field.onChange(emoji);
|
||||||
|
}}
|
||||||
|
emojiSize={32}
|
||||||
|
className="w-full rounded-2xl px-2"
|
||||||
|
>
|
||||||
|
<EmojiPicker.Group>
|
||||||
|
<EmojiPicker.List
|
||||||
|
hideStickyHeader
|
||||||
|
containerHeight={295}
|
||||||
|
/>
|
||||||
|
</EmojiPicker.Group>
|
||||||
|
</EmojiPicker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="mt-2 min-w-[18rem]"
|
||||||
|
disabled={!form.formState.isValid || isPending}
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import LibraryFolderCreationDialog from "../LibraryFolderCreationDialog/LibraryFolderCreationDialog";
|
||||||
|
import { LibraryTabs, Tab } from "../LibraryTabs/LibraryTabs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTab: string;
|
||||||
|
onTabChange: (tabId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibrarySubSection({ tabs, activeTab, onTabChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<LibraryTabs
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
/>
|
||||||
|
<LibraryFolderCreationDialog />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"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,28 +1,55 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { FavoritesSection } from "./components/FavoritesSection/FavoritesSection";
|
import { HeartIcon, ListIcon } from "@phosphor-icons/react";
|
||||||
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 (
|
||||||
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
|
<FavoriteAnimationProvider
|
||||||
<LibraryActionHeader setSearchTerm={setSearchTerm} />
|
onAnimationComplete={handleFavoriteAnimationComplete}
|
||||||
<FavoritesSection searchTerm={searchTerm} />
|
>
|
||||||
<LibraryAgentList
|
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
|
||||||
searchTerm={searchTerm}
|
<LibraryActionHeader setSearchTerm={setSearchTerm} />
|
||||||
librarySort={librarySort}
|
<LibraryAgentList
|
||||||
setLibrarySort={setLibrarySort}
|
searchTerm={searchTerm}
|
||||||
/>
|
librarySort={librarySort}
|
||||||
</main>
|
setLibrarySort={setLibrarySort}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
onFolderSelect={setSelectedFolderId}
|
||||||
|
tabs={LIBRARY_TABS}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</FavoriteAnimationProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3619,6 +3619,29 @@
|
|||||||
"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": {
|
||||||
@@ -3986,6 +4009,338 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/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)" },
|
||||||
|
"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"],
|
||||||
@@ -7536,6 +7891,23 @@
|
|||||||
"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" },
|
||||||
@@ -8259,6 +8631,96 @@
|
|||||||
"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" },
|
||||||
@@ -9147,6 +9609,14 @@
|
|||||||
"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"
|
||||||
@@ -9414,12 +9884,109 @@
|
|||||||
{ "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 (None to move to 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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user