mirror of https://github.com/kortix-ai/suna.git
feat: agentbuilder v1
This commit is contained in:
parent
d53f2f7a18
commit
7ce20533a6
|
@ -0,0 +1,130 @@
|
|||
# Pagination Implementation for Agents and Marketplace
|
||||
|
||||
This document outlines the implementation of server-side pagination, searching, sorting, and filtering for both the agents page and marketplace page.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### 1. Updated API Endpoints
|
||||
|
||||
#### Agents Endpoint (`/agents`)
|
||||
- **New Parameters:**
|
||||
- `page`: Page number (1-based, default: 1)
|
||||
- `limit`: Items per page (1-100, default: 20)
|
||||
- `search`: Search in name and description
|
||||
- `sort_by`: Sort field (name, created_at, updated_at, tools_count)
|
||||
- `sort_order`: Sort order (asc, desc)
|
||||
- `has_default`: Filter by default agents
|
||||
- `has_mcp_tools`: Filter by agents with MCP tools
|
||||
- `has_agentpress_tools`: Filter by agents with AgentPress tools
|
||||
- `tools`: Comma-separated list of tools to filter by
|
||||
|
||||
- **Response Format:**
|
||||
```json
|
||||
{
|
||||
"agents": [...],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 150,
|
||||
"pages": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Marketplace Endpoint (`/marketplace/agents`)
|
||||
- **New Parameters:**
|
||||
- `page`: Page number (1-based, default: 1)
|
||||
- `limit`: Items per page (1-100, default: 20)
|
||||
- `search`: Search in name and description
|
||||
- `tags`: Comma-separated string of tags
|
||||
- `sort_by`: Sort by (newest, popular, most_downloaded, name)
|
||||
- `creator`: Filter by creator name
|
||||
|
||||
- **Response Format:**
|
||||
```json
|
||||
{
|
||||
"agents": [...],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 75,
|
||||
"pages": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Functions
|
||||
|
||||
#### Updated `get_marketplace_agents`
|
||||
- Added `p_creator` parameter for filtering by creator name
|
||||
- Enhanced search functionality
|
||||
|
||||
#### New `get_marketplace_agents_count`
|
||||
- Returns total count of marketplace agents matching filters
|
||||
- Used for pagination calculation
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### 1. New Components
|
||||
|
||||
#### Pagination Component
|
||||
- Located at: `frontend/src/app/(dashboard)/agents/_components/pagination.tsx`
|
||||
- Features:
|
||||
- Smart page number display with ellipsis
|
||||
- Previous/Next navigation
|
||||
- Disabled state during loading
|
||||
- Responsive design
|
||||
|
||||
### 2. Updated Hooks
|
||||
|
||||
#### useAgents Hook
|
||||
- Now accepts `AgentsParams` for server-side filtering
|
||||
- Returns `AgentsResponse` with pagination info
|
||||
|
||||
#### useMarketplaceAgents Hook
|
||||
- Updated to support new pagination parameters
|
||||
- Returns `MarketplaceAgentsResponse` with pagination info
|
||||
|
||||
### 3. Updated Pages
|
||||
|
||||
#### Agents Page
|
||||
- Replaced client-side filtering with server-side parameters
|
||||
- Added pagination component
|
||||
- Automatic page reset when filters change
|
||||
- Enhanced results display with pagination info
|
||||
|
||||
#### Marketplace Page
|
||||
- Added pagination support
|
||||
- Enhanced sorting options (added "Name A-Z")
|
||||
- Improved results display
|
||||
- Added pagination component
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Performance**: Only loads necessary data, reducing bandwidth and improving load times
|
||||
2. **Scalability**: Can handle large datasets efficiently
|
||||
3. **User Experience**: Faster page loads and responsive filtering
|
||||
4. **Server Resources**: Reduced memory usage and database load
|
||||
5. **Search**: Real-time search with backend optimization
|
||||
|
||||
## Usage
|
||||
|
||||
### Agents Page
|
||||
- Search agents by name or description
|
||||
- Filter by default status, tool types, or specific tools
|
||||
- Sort by name, creation date, update date, or tool count
|
||||
- Navigate through pages with pagination controls
|
||||
|
||||
### Marketplace Page
|
||||
- Search marketplace agents by name or description
|
||||
- Filter by tags or creator name
|
||||
- Sort by newest, popularity, downloads, or name
|
||||
- Browse through paginated results
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Page size is limited to 100 items maximum for performance
|
||||
- Search is case-insensitive and matches partial strings
|
||||
- Tool filtering supports both MCP and AgentPress tools
|
||||
- Sorting is handled both in database (for simple fields) and post-processing (for computed fields like tools_count)
|
||||
- Pagination automatically resets to page 1 when filters change
|
|
@ -1,4 +1,4 @@
|
|||
from fastapi import APIRouter, HTTPException, Depends, Request, Body, File, UploadFile, Form
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Body, File, UploadFile, Form, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
import asyncio
|
||||
import json
|
||||
|
@ -82,6 +82,16 @@ class AgentResponse(BaseModel):
|
|||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
class PaginationInfo(BaseModel):
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
pages: int
|
||||
|
||||
class AgentsResponse(BaseModel):
|
||||
agents: List[AgentResponse]
|
||||
pagination: PaginationInfo
|
||||
|
||||
class ThreadAgentResponse(BaseModel):
|
||||
agent: Optional[AgentResponse]
|
||||
source: str # "thread", "default", "none", "missing"
|
||||
|
@ -1032,23 +1042,141 @@ async def initiate_agent_with_files(
|
|||
# TODO: Clean up created project/thread if initiation fails mid-way
|
||||
raise HTTPException(status_code=500, detail=f"Failed to initiate agent session: {str(e)}")
|
||||
|
||||
@router.get("/agents", response_model=List[AgentResponse])
|
||||
async def get_agents(user_id: str = Depends(get_current_user_id_from_jwt)):
|
||||
"""Get all agents for the current user."""
|
||||
logger.info(f"Fetching agents for user: {user_id}")
|
||||
@router.get("/agents", response_model=AgentsResponse)
|
||||
async def get_agents(
|
||||
user_id: str = Depends(get_current_user_id_from_jwt),
|
||||
page: Optional[int] = Query(1, ge=1, description="Page number (1-based)"),
|
||||
limit: Optional[int] = Query(20, ge=1, le=100, description="Number of items per page"),
|
||||
search: Optional[str] = Query(None, description="Search in name and description"),
|
||||
sort_by: Optional[str] = Query("created_at", description="Sort field: name, created_at, updated_at, tools_count"),
|
||||
sort_order: Optional[str] = Query("desc", description="Sort order: asc, desc"),
|
||||
has_default: Optional[bool] = Query(None, description="Filter by default agents"),
|
||||
has_mcp_tools: Optional[bool] = Query(None, description="Filter by agents with MCP tools"),
|
||||
has_agentpress_tools: Optional[bool] = Query(None, description="Filter by agents with AgentPress tools"),
|
||||
tools: Optional[str] = Query(None, description="Comma-separated list of tools to filter by")
|
||||
):
|
||||
"""Get agents for the current user with pagination, search, sort, and filter support."""
|
||||
logger.info(f"Fetching agents for user: {user_id} with page={page}, limit={limit}, search='{search}', sort_by={sort_by}, sort_order={sort_order}")
|
||||
client = await db.client
|
||||
|
||||
try:
|
||||
# Get agents for the current user's account
|
||||
agents = await client.table('agents').select('*').eq("account_id", user_id).order('created_at', desc=True).execute()
|
||||
# Calculate offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
if not agents.data:
|
||||
# Start building the query
|
||||
query = client.table('agents').select('*', count='exact').eq("account_id", user_id)
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.or_(f"name.ilike.{search_term},description.ilike.{search_term}")
|
||||
|
||||
# Apply filters
|
||||
if has_default is not None:
|
||||
query = query.eq("is_default", has_default)
|
||||
|
||||
# For MCP and AgentPress tools filtering, we'll need to do post-processing
|
||||
# since Supabase doesn't have great JSON array/object filtering
|
||||
|
||||
# Apply sorting
|
||||
if sort_by == "name":
|
||||
query = query.order("name", desc=(sort_order == "desc"))
|
||||
elif sort_by == "updated_at":
|
||||
query = query.order("updated_at", desc=(sort_order == "desc"))
|
||||
elif sort_by == "created_at":
|
||||
query = query.order("created_at", desc=(sort_order == "desc"))
|
||||
else:
|
||||
# Default to created_at
|
||||
query = query.order("created_at", desc=(sort_order == "desc"))
|
||||
|
||||
# Execute query to get total count first
|
||||
count_result = await query.execute()
|
||||
total_count = count_result.count
|
||||
|
||||
# Now get the actual data with pagination
|
||||
query = query.range(offset, offset + limit - 1)
|
||||
agents_result = await query.execute()
|
||||
|
||||
if not agents_result.data:
|
||||
logger.info(f"No agents found for user: {user_id}")
|
||||
return []
|
||||
return {
|
||||
"agents": [],
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": 0,
|
||||
"pages": 0
|
||||
}
|
||||
}
|
||||
|
||||
# Post-process for tool filtering and tools_count sorting
|
||||
agents_data = agents_result.data
|
||||
|
||||
# Apply tool-based filters
|
||||
if has_mcp_tools is not None or has_agentpress_tools is not None or tools:
|
||||
filtered_agents = []
|
||||
tools_filter = []
|
||||
if tools:
|
||||
tools_filter = [tool.strip() for tool in tools.split(',') if tool.strip()]
|
||||
|
||||
for agent in agents_data:
|
||||
# Check MCP tools filter
|
||||
if has_mcp_tools is not None:
|
||||
has_mcp = bool(agent.get('configured_mcps') and len(agent.get('configured_mcps', [])) > 0)
|
||||
if has_mcp_tools != has_mcp:
|
||||
continue
|
||||
|
||||
# Check AgentPress tools filter
|
||||
if has_agentpress_tools is not None:
|
||||
agentpress_tools = agent.get('agentpress_tools', {})
|
||||
has_enabled_tools = any(
|
||||
tool_data and isinstance(tool_data, dict) and tool_data.get('enabled', False)
|
||||
for tool_data in agentpress_tools.values()
|
||||
)
|
||||
if has_agentpress_tools != has_enabled_tools:
|
||||
continue
|
||||
|
||||
# Check specific tools filter
|
||||
if tools_filter:
|
||||
agent_tools = set()
|
||||
# Add MCP tools
|
||||
for mcp in agent.get('configured_mcps', []):
|
||||
if isinstance(mcp, dict) and 'name' in mcp:
|
||||
agent_tools.add(f"mcp:{mcp['name']}")
|
||||
|
||||
# Add enabled AgentPress tools
|
||||
for tool_name, tool_data in agent.get('agentpress_tools', {}).items():
|
||||
if tool_data and isinstance(tool_data, dict) and tool_data.get('enabled', False):
|
||||
agent_tools.add(f"agentpress:{tool_name}")
|
||||
|
||||
# Check if any of the requested tools are present
|
||||
if not any(tool in agent_tools for tool in tools_filter):
|
||||
continue
|
||||
|
||||
filtered_agents.append(agent)
|
||||
|
||||
agents_data = filtered_agents
|
||||
|
||||
# Handle tools_count sorting (post-processing required)
|
||||
if sort_by == "tools_count":
|
||||
def get_tools_count(agent):
|
||||
mcp_count = len(agent.get('configured_mcps', []))
|
||||
agentpress_count = sum(
|
||||
1 for tool_data in agent.get('agentpress_tools', {}).values()
|
||||
if tool_data and isinstance(tool_data, dict) and tool_data.get('enabled', False)
|
||||
)
|
||||
return mcp_count + agentpress_count
|
||||
|
||||
agents_data.sort(key=get_tools_count, reverse=(sort_order == "desc"))
|
||||
|
||||
# Apply pagination to filtered results if we did post-processing
|
||||
if has_mcp_tools is not None or has_agentpress_tools is not None or tools or sort_by == "tools_count":
|
||||
total_count = len(agents_data)
|
||||
agents_data = agents_data[offset:offset + limit]
|
||||
|
||||
# Format the response
|
||||
agent_list = []
|
||||
for agent in agents.data:
|
||||
for agent in agents_data:
|
||||
agent_list.append(AgentResponse(
|
||||
agent_id=agent['agent_id'],
|
||||
account_id=agent['account_id'],
|
||||
|
@ -1068,8 +1196,18 @@ async def get_agents(user_id: str = Depends(get_current_user_id_from_jwt)):
|
|||
updated_at=agent['updated_at']
|
||||
))
|
||||
|
||||
logger.info(f"Found {len(agent_list)} agents for user: {user_id}")
|
||||
return agent_list
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
logger.info(f"Found {len(agent_list)} agents for user: {user_id} (page {page}/{total_pages})")
|
||||
return {
|
||||
"agents": agent_list,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total_count,
|
||||
"pages": total_pages
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching agents for user {user_id}: {str(e)}")
|
||||
|
@ -1326,40 +1464,75 @@ class MarketplaceAgent(BaseModel):
|
|||
|
||||
class MarketplaceAgentsResponse(BaseModel):
|
||||
agents: List[MarketplaceAgent]
|
||||
pagination: PaginationInfo
|
||||
|
||||
class PublishAgentRequest(BaseModel):
|
||||
tags: Optional[List[str]] = []
|
||||
|
||||
@router.get("/marketplace/agents", response_model=MarketplaceAgentsResponse)
|
||||
async def get_marketplace_agents(
|
||||
search: Optional[str] = None,
|
||||
tags: Optional[str] = None, # Comma-separated string
|
||||
limit: Optional[int] = 50,
|
||||
offset: Optional[int] = 0
|
||||
page: Optional[int] = Query(1, ge=1, description="Page number (1-based)"),
|
||||
limit: Optional[int] = Query(20, ge=1, le=100, description="Number of items per page"),
|
||||
search: Optional[str] = Query(None, description="Search in name and description"),
|
||||
tags: Optional[str] = Query(None, description="Comma-separated string of tags"),
|
||||
sort_by: Optional[str] = Query("newest", description="Sort by: newest, popular, most_downloaded, name"),
|
||||
creator: Optional[str] = Query(None, description="Filter by creator name")
|
||||
):
|
||||
"""Get public agents from the marketplace."""
|
||||
logger.info(f"Fetching marketplace agents with search='{search}', tags='{tags}', limit={limit}, offset={offset}")
|
||||
"""Get public agents from the marketplace with pagination, search, sort, and filter support."""
|
||||
logger.info(f"Fetching marketplace agents with page={page}, limit={limit}, search='{search}', tags='{tags}', sort_by={sort_by}")
|
||||
client = await db.client
|
||||
|
||||
try:
|
||||
# Convert tags string to array if provided
|
||||
offset = (page - 1) * limit
|
||||
tags_array = None
|
||||
if tags:
|
||||
tags_array = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
|
||||
# Call the database function
|
||||
result = await client.rpc('get_marketplace_agents', {
|
||||
'p_search': search,
|
||||
'p_tags': tags_array,
|
||||
'p_limit': limit,
|
||||
'p_limit': limit + 1,
|
||||
'p_offset': offset
|
||||
}).execute()
|
||||
|
||||
if result.data is None:
|
||||
result.data = []
|
||||
|
||||
logger.info(f"Found {len(result.data)} marketplace agents")
|
||||
return {"agents": result.data}
|
||||
has_more = len(result.data) > limit
|
||||
agents_data = result.data[:limit]
|
||||
if creator:
|
||||
agents_data = [
|
||||
agent for agent in agents_data
|
||||
if creator.lower() in agent.get('creator_name', '').lower()
|
||||
]
|
||||
|
||||
if sort_by == "most_downloaded":
|
||||
agents_data = sorted(agents_data, key=lambda x: x.get('download_count', 0), reverse=True)
|
||||
elif sort_by == "popular":
|
||||
agents_data = sorted(agents_data, key=lambda x: x.get('download_count', 0), reverse=True)
|
||||
elif sort_by == "name":
|
||||
agents_data = sorted(agents_data, key=lambda x: x.get('name', '').lower())
|
||||
else:
|
||||
agents_data = sorted(agents_data, key=lambda x: x.get('marketplace_published_at', ''), reverse=True)
|
||||
|
||||
estimated_total = (page - 1) * limit + len(agents_data)
|
||||
if has_more:
|
||||
estimated_total += 1
|
||||
|
||||
total_pages = max(page, (estimated_total + limit - 1) // limit)
|
||||
if has_more:
|
||||
total_pages = page + 1
|
||||
|
||||
logger.info(f"Found {len(agents_data)} marketplace agents (page {page}, estimated {total_pages} pages)")
|
||||
return {
|
||||
"agents": agents_data,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": estimated_total,
|
||||
"pages": total_pages
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching marketplace agents: {str(e)}")
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
isLoading = false
|
||||
}) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const getVisiblePages = () => {
|
||||
const delta = 2;
|
||||
const range = [];
|
||||
const rangeWithDots = [];
|
||||
|
||||
for (let i = Math.max(2, currentPage - delta); i <= Math.min(totalPages - 1, currentPage + delta); i++) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
if (currentPage - delta > 2) {
|
||||
rangeWithDots.push(1, '...');
|
||||
} else {
|
||||
rangeWithDots.push(1);
|
||||
}
|
||||
|
||||
rangeWithDots.push(...range);
|
||||
|
||||
if (currentPage + delta < totalPages - 1) {
|
||||
rangeWithDots.push('...', totalPages);
|
||||
} else if (totalPages > 1) {
|
||||
rangeWithDots.push(totalPages);
|
||||
}
|
||||
|
||||
return rangeWithDots;
|
||||
};
|
||||
|
||||
const visiblePages = getVisiblePages();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1 || isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{visiblePages.map((page, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{page === '...' ? (
|
||||
<div className="flex h-8 w-8 items-center justify-center">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page as number)}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -8,6 +8,8 @@ interface ResultsInfoProps {
|
|||
searchQuery: string;
|
||||
activeFiltersCount: number;
|
||||
clearFilters: () => void;
|
||||
currentPage?: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
export const ResultsInfo = ({
|
||||
|
@ -16,16 +18,25 @@ export const ResultsInfo = ({
|
|||
filteredCount,
|
||||
searchQuery,
|
||||
activeFiltersCount,
|
||||
clearFilters
|
||||
clearFilters,
|
||||
currentPage,
|
||||
totalPages
|
||||
}: ResultsInfoProps) => {
|
||||
if (isLoading || totalAgents === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showingText = () => {
|
||||
if (currentPage && totalPages && totalPages > 1) {
|
||||
return `Showing page ${currentPage} of ${totalPages} (${totalAgents} total agents)`;
|
||||
}
|
||||
return `Showing ${filteredCount} of ${totalAgents} agents`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
Showing {filteredCount} of {totalAgents} agents
|
||||
{showingText()}
|
||||
{searchQuery && ` for "${searchQuery}"`}
|
||||
</span>
|
||||
{activeFiltersCount > 0 && (
|
||||
|
|
|
@ -33,6 +33,9 @@ export default function AgentConfigurationPage() {
|
|||
const updateAgentMutation = useUpdateAgent();
|
||||
const { state, setOpen, setOpenMobile } = useSidebar();
|
||||
|
||||
// Ref to track if initial layout has been applied (for sidebar closing)
|
||||
const initialLayoutAppliedRef = useRef(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
|
@ -52,6 +55,14 @@ export default function AgentConfigurationPage() {
|
|||
const [activeTab, setActiveTab] = useState('agent-builder');
|
||||
const accordionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Effect to automatically close sidebar on page load
|
||||
useEffect(() => {
|
||||
if (!initialLayoutAppliedRef.current) {
|
||||
setOpen(false);
|
||||
initialLayoutAppliedRef.current = true;
|
||||
}
|
||||
}, [setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agent) {
|
||||
const agentData = agent as any;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, AlertCircle } from 'lucide-react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { UpdateAgentDialog } from './_components/update-agent-dialog';
|
||||
|
@ -12,44 +12,119 @@ import { EmptyState } from './_components/empty-state';
|
|||
import { AgentsGrid } from './_components/agents-grid';
|
||||
import { AgentsList } from './_components/agents-list';
|
||||
import { LoadingState } from './_components/loading-state';
|
||||
import { useAgentsFiltering } from './_hooks/use-agents-filtering';
|
||||
import { Pagination } from './_components/pagination';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DEFAULT_AGENTPRESS_TOOLS } from './_data/tools';
|
||||
import { AgentsParams } from '@/hooks/react-query/agents/utils';
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
type SortOption = 'name' | 'created_at' | 'updated_at' | 'tools_count';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
interface FilterOptions {
|
||||
hasDefaultAgent: boolean;
|
||||
hasMcpTools: boolean;
|
||||
hasAgentpressTools: boolean;
|
||||
selectedTools: string[];
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const router = useRouter();
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingAgentId, setEditingAgentId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
|
||||
// Server-side parameters
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('created_at');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||
const [filters, setFilters] = useState<FilterOptions>({
|
||||
hasDefaultAgent: false,
|
||||
hasMcpTools: false,
|
||||
hasAgentpressTools: false,
|
||||
selectedTools: []
|
||||
});
|
||||
|
||||
// Build query parameters
|
||||
const queryParams: AgentsParams = useMemo(() => {
|
||||
const params: AgentsParams = {
|
||||
page,
|
||||
limit: 20,
|
||||
search: searchQuery || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
};
|
||||
|
||||
if (filters.hasDefaultAgent) {
|
||||
params.has_default = true;
|
||||
}
|
||||
if (filters.hasMcpTools) {
|
||||
params.has_mcp_tools = true;
|
||||
}
|
||||
if (filters.hasAgentpressTools) {
|
||||
params.has_agentpress_tools = true;
|
||||
}
|
||||
if (filters.selectedTools.length > 0) {
|
||||
params.tools = filters.selectedTools.join(',');
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [page, searchQuery, sortBy, sortOrder, filters]);
|
||||
|
||||
const {
|
||||
data: agents = [],
|
||||
data: agentsResponse,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: loadAgents
|
||||
} = useAgents();
|
||||
} = useAgents(queryParams);
|
||||
|
||||
const updateAgentMutation = useUpdateAgent();
|
||||
const deleteAgentMutation = useDeleteAgent();
|
||||
const createAgentMutation = useCreateAgent();
|
||||
const { optimisticallyUpdateAgent, revertOptimisticUpdate } = useOptimisticAgentUpdate();
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
filters,
|
||||
setFilters,
|
||||
filteredAndSortedAgents,
|
||||
allTools,
|
||||
activeFiltersCount,
|
||||
clearFilters
|
||||
} = useAgentsFiltering(agents);
|
||||
const agents = agentsResponse?.agents || [];
|
||||
const pagination = agentsResponse?.pagination;
|
||||
|
||||
// Get all tools for filter options (we'll need to fetch this separately or compute from current page)
|
||||
const allTools = useMemo(() => {
|
||||
const toolsSet = new Set<string>();
|
||||
agents.forEach(agent => {
|
||||
agent.configured_mcps?.forEach(mcp => toolsSet.add(`mcp:${mcp.name}`));
|
||||
Object.entries(agent.agentpress_tools || {}).forEach(([tool, toolData]) => {
|
||||
if (toolData && typeof toolData === 'object' && 'enabled' in toolData && toolData.enabled) {
|
||||
toolsSet.add(`agentpress:${tool}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(toolsSet).sort();
|
||||
}, [agents]);
|
||||
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filters.hasDefaultAgent) count++;
|
||||
if (filters.hasMcpTools) count++;
|
||||
if (filters.hasAgentpressTools) count++;
|
||||
count += filters.selectedTools.length;
|
||||
return count;
|
||||
}, [filters]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setFilters({
|
||||
hasDefaultAgent: false,
|
||||
hasMcpTools: false,
|
||||
hasAgentpressTools: false,
|
||||
selectedTools: []
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// Reset page when search or filters change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [searchQuery, sortBy, sortOrder, filters]);
|
||||
|
||||
const handleDeleteAgent = async (agentId: string) => {
|
||||
try {
|
||||
|
@ -133,7 +208,7 @@ export default function AgentsPage() {
|
|||
>
|
||||
{createAgentMutation.isPending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-background border-t-transparent" />
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
@ -163,24 +238,26 @@ export default function AgentsPage() {
|
|||
|
||||
<ResultsInfo
|
||||
isLoading={isLoading}
|
||||
totalAgents={agents.length}
|
||||
filteredCount={filteredAndSortedAgents.length}
|
||||
totalAgents={pagination?.total || 0}
|
||||
filteredCount={agents.length}
|
||||
searchQuery={searchQuery}
|
||||
activeFiltersCount={activeFiltersCount}
|
||||
clearFilters={clearFilters}
|
||||
currentPage={pagination?.page || 1}
|
||||
totalPages={pagination?.pages || 1}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingState viewMode={viewMode} />
|
||||
) : filteredAndSortedAgents.length === 0 ? (
|
||||
) : agents.length === 0 ? (
|
||||
<EmptyState
|
||||
hasAgents={agents.length > 0}
|
||||
hasAgents={(pagination?.total || 0) > 0}
|
||||
onCreateAgent={handleCreateNewAgent}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
) : (
|
||||
<AgentsGrid
|
||||
agents={filteredAndSortedAgents}
|
||||
agents={agents}
|
||||
onEditAgent={handleEditAgent}
|
||||
onDeleteAgent={handleDeleteAgent}
|
||||
onToggleDefault={handleToggleDefault}
|
||||
|
@ -188,6 +265,15 @@ export default function AgentsPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{pagination && pagination.pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.pages}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpdateAgentDialog
|
||||
agentId={editingAgentId}
|
||||
isOpen={editDialogOpen}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Search, Download, Star, Calendar, User, Tags, TrendingUp, Globe } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
@ -11,23 +11,35 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
|||
import { toast } from 'sonner';
|
||||
import { getAgentAvatar } from '../agents/_utils/get-agent-style';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Pagination } from '../agents/_components/pagination';
|
||||
|
||||
type SortOption = 'newest' | 'popular' | 'most_downloaded';
|
||||
type SortOption = 'newest' | 'popular' | 'most_downloaded' | 'name';
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('newest');
|
||||
const [addingAgentId, setAddingAgentId] = useState<string | null>(null);
|
||||
|
||||
const { data: agents = [], isLoading, error } = useMarketplaceAgents({
|
||||
search: searchQuery,
|
||||
const queryParams = useMemo(() => ({
|
||||
page,
|
||||
limit: 20,
|
||||
search: searchQuery || undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
sort: sortBy
|
||||
});
|
||||
|
||||
sort_by: sortBy
|
||||
}), [page, searchQuery, selectedTags, sortBy]);
|
||||
|
||||
const { data: agentsResponse, isLoading, error } = useMarketplaceAgents(queryParams);
|
||||
const addToLibraryMutation = useAddAgentToLibrary();
|
||||
|
||||
const agents = agentsResponse?.agents || [];
|
||||
const pagination = agentsResponse?.pagination;
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [searchQuery, selectedTags, sortBy]);
|
||||
|
||||
const handleAddToLibrary = async (agentId: string, agentName: string) => {
|
||||
try {
|
||||
setAddingAgentId(agentId);
|
||||
|
@ -156,6 +168,8 @@ export default function MarketplacePage() {
|
|||
<div className="text-sm text-muted-foreground">
|
||||
{isLoading ? (
|
||||
"Loading agents..."
|
||||
) : pagination ? (
|
||||
`Showing page ${pagination.page} of ${pagination.pages} (${pagination.total} total agents)`
|
||||
) : (
|
||||
`${agents.length} agent${agents.length !== 1 ? 's' : ''} found`
|
||||
)}
|
||||
|
@ -163,7 +177,7 @@ export default function MarketplacePage() {
|
|||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden">
|
||||
<Skeleton className="h-50" />
|
||||
<div className="p-4 space-y-3">
|
||||
|
@ -193,7 +207,7 @@ export default function MarketplacePage() {
|
|||
return (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden hover:bg-muted/50 transition-all duration-200 cursor-pointer group"
|
||||
className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden hover:bg-muted/50 transition-all duration-200 cursor-pointer group flex flex-col h-full"
|
||||
>
|
||||
<div className={`h-50 flex items-center justify-center relative`} style={{ backgroundColor: color }}>
|
||||
<div className="text-4xl">
|
||||
|
@ -207,7 +221,7 @@ export default function MarketplacePage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-foreground font-medium text-lg line-clamp-1 flex-1">
|
||||
{agent.name}
|
||||
|
@ -249,7 +263,7 @@ export default function MarketplacePage() {
|
|||
handleAddToLibrary(agent.agent_id, agent.name);
|
||||
}}
|
||||
disabled={addingAgentId === agent.agent_id}
|
||||
className="w-full transition-opacity"
|
||||
className="w-full transition-opacity mt-auto"
|
||||
size="sm"
|
||||
key={agent.agent_id}
|
||||
>
|
||||
|
@ -271,6 +285,15 @@ export default function MarketplacePage() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.pages}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -29,18 +29,24 @@ export function AgentSelector({
|
|||
className,
|
||||
variant = 'default'
|
||||
}: AgentSelectorProps) {
|
||||
const { data: agents = [], isLoading, refetch: loadAgents } = useAgents();
|
||||
const { data: agentsResponse, isLoading, refetch: loadAgents } = useAgents({
|
||||
limit: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
const agents = agentsResponse?.agents || [];
|
||||
const defaultAgent = agents.find(agent => agent.is_default);
|
||||
const currentAgent = selectedAgentId
|
||||
? agents.find(agent => agent.agent_id === selectedAgentId)
|
||||
: null;
|
||||
|
||||
// Display name logic: show selected agent, default agent, or "Suna" as fallback
|
||||
const displayName = currentAgent?.name || defaultAgent?.name || 'Suna';
|
||||
const agentAvatar = currentAgent?.avatar;
|
||||
const isUsingSuna = !currentAgent && !defaultAgent;
|
||||
|
||||
const handleAgentSelect = (agentId: string | undefined) => {
|
||||
|
@ -96,6 +102,9 @@ export function AgentSelector({
|
|||
>
|
||||
<span className="underline decoration-dashed underline-offset-6 decoration-muted-foreground/50 tracking-tight text-4xl font-semibold leading-tight text-primary">
|
||||
{displayName}
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{agentAvatar && agentAvatar}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
|
|
|
@ -2,17 +2,19 @@ import { createMutationHook, createQueryHook } from '@/hooks/use-query';
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { agentKeys } from './keys';
|
||||
import { Agent, AgentUpdateRequest, createAgent, deleteAgent, getAgent, getAgents, getThreadAgent, updateAgent, AgentBuilderChatRequest, AgentBuilderStreamData, startAgentBuilderChat, getAgentBuilderChatHistory } from './utils';
|
||||
import { Agent, AgentUpdateRequest, AgentsParams, createAgent, deleteAgent, getAgent, getAgents, getThreadAgent, updateAgent, AgentBuilderChatRequest, AgentBuilderStreamData, startAgentBuilderChat, getAgentBuilderChatHistory } from './utils';
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
export const useAgents = createQueryHook(
|
||||
agentKeys.list(),
|
||||
getAgents,
|
||||
{
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
}
|
||||
);
|
||||
export const useAgents = (params: AgentsParams = {}) => {
|
||||
return createQueryHook(
|
||||
agentKeys.list(params),
|
||||
() => getAgents(params),
|
||||
{
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
}
|
||||
)();
|
||||
};
|
||||
|
||||
export const useAgent = (agentId: string) => {
|
||||
return createQueryHook(
|
||||
|
|
|
@ -24,6 +24,30 @@ export type Agent = {
|
|||
avatar_color?: string;
|
||||
};
|
||||
|
||||
export type PaginationInfo = {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
export type AgentsResponse = {
|
||||
agents: Agent[];
|
||||
pagination: PaginationInfo;
|
||||
};
|
||||
|
||||
export type AgentsParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
has_default?: boolean;
|
||||
has_mcp_tools?: boolean;
|
||||
has_agentpress_tools?: boolean;
|
||||
tools?: string;
|
||||
};
|
||||
|
||||
export type ThreadAgentResponse = {
|
||||
agent: Agent | null;
|
||||
source: 'thread' | 'default' | 'none' | 'missing';
|
||||
|
@ -54,7 +78,7 @@ export type AgentUpdateRequest = {
|
|||
is_default?: boolean;
|
||||
};
|
||||
|
||||
export const getAgents = async (): Promise<Agent[]> => {
|
||||
export const getAgents = async (params: AgentsParams = {}): Promise<AgentsResponse> => {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
@ -63,7 +87,20 @@ export const getAgents = async (): Promise<Agent[]> => {
|
|||
throw new Error('You must be logged in to get agents');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/agents`, {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.page) queryParams.append('page', params.page.toString());
|
||||
if (params.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params.search) queryParams.append('search', params.search);
|
||||
if (params.sort_by) queryParams.append('sort_by', params.sort_by);
|
||||
if (params.sort_order) queryParams.append('sort_order', params.sort_order);
|
||||
if (params.has_default !== undefined) queryParams.append('has_default', params.has_default.toString());
|
||||
if (params.has_mcp_tools !== undefined) queryParams.append('has_mcp_tools', params.has_mcp_tools.toString());
|
||||
if (params.has_agentpress_tools !== undefined) queryParams.append('has_agentpress_tools', params.has_agentpress_tools.toString());
|
||||
if (params.tools) queryParams.append('tools', params.tools);
|
||||
|
||||
const url = `${API_URL}/agents${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -76,9 +113,9 @@ export const getAgents = async (): Promise<Agent[]> => {
|
|||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const agents = await response.json();
|
||||
console.log('[API] Fetched agents:', agents.length);
|
||||
return agents;
|
||||
const result = await response.json();
|
||||
console.log('[API] Fetched agents:', result.agents?.length || 0, 'total:', result.pagination?.total || 0);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Error fetching agents:', err);
|
||||
throw err;
|
||||
|
|
|
@ -19,29 +19,44 @@ export interface MarketplaceAgent {
|
|||
avatar_color?: string;
|
||||
}
|
||||
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface MarketplaceAgentsResponse {
|
||||
agents: MarketplaceAgent[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
interface MarketplaceAgentsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
sort?: 'newest' | 'popular' | 'most_downloaded';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort_by?: 'newest' | 'popular' | 'most_downloaded' | 'name';
|
||||
creator?: string;
|
||||
}
|
||||
|
||||
export function useMarketplaceAgents(params: MarketplaceAgentsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['marketplace-agents', params],
|
||||
queryFn: async (): Promise<MarketplaceAgent[]> => {
|
||||
queryFn: async (): Promise<MarketplaceAgentsResponse> => {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.page) queryParams.append('page', params.page.toString());
|
||||
if (params.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params.search) queryParams.append('search', params.search);
|
||||
if (params.tags && params.tags.length > 0) {
|
||||
queryParams.append('tags', params.tags.join(','));
|
||||
}
|
||||
if (params.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params.offset) queryParams.append('offset', params.offset.toString());
|
||||
if (params.sort_by) queryParams.append('sort_by', params.sort_by);
|
||||
if (params.creator) queryParams.append('creator', params.creator);
|
||||
|
||||
const url = `${API_URL}/marketplace/agents${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
|
@ -64,25 +79,8 @@ export function useMarketplaceAgents(params: MarketplaceAgentsParams = {}) {
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
let agents = data.agents || [];
|
||||
|
||||
// Sort the results based on the sort parameter
|
||||
switch (params.sort) {
|
||||
case 'most_downloaded':
|
||||
agents = agents.sort((a, b) => b.download_count - a.download_count);
|
||||
break;
|
||||
case 'popular':
|
||||
agents = agents.sort((a, b) => b.download_count - a.download_count);
|
||||
break;
|
||||
case 'newest':
|
||||
default:
|
||||
agents = agents.sort((a, b) =>
|
||||
new Date(b.marketplace_published_at).getTime() - new Date(a.marketplace_published_at).getTime()
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return agents;
|
||||
console.log('[API] Fetched marketplace agents:', data.agents?.length || 0, 'total:', data.pagination?.total || 0);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Error fetching marketplace agents:', err);
|
||||
throw err;
|
||||
|
|
Loading…
Reference in New Issue