Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
d8cc94acb6 Add favorites feature for library agents with sorting and UI updates
Co-authored-by: nicholas.tindle <nicholas.tindle@agpt.co>
2025-09-02 17:17:05 +00:00
16 changed files with 201 additions and 43 deletions

View File

@@ -91,12 +91,15 @@ async def list_library_agents(
]
# Determine sorting
order_by: prisma.types.LibraryAgentOrderByInput | None = None
order_by: list[prisma.types.LibraryAgentOrderByInput] | prisma.types.LibraryAgentOrderByInput | None = None
if sort_by == library_model.LibraryAgentSort.CREATED_AT:
order_by = {"createdAt": "asc"}
elif sort_by == library_model.LibraryAgentSort.UPDATED_AT:
order_by = {"updatedAt": "desc"}
elif sort_by == library_model.LibraryAgentSort.FAVORITES_FIRST:
# Sort by favorites first, then by updated date
order_by = [{"isFavorite": "desc"}, {"updatedAt": "desc"}]
try:
library_agents = await prisma.models.LibraryAgent.prisma().find_many(

View File

@@ -64,6 +64,9 @@ class LibraryAgent(pydantic.BaseModel):
# Indicates if this agent is the latest version
is_latest_version: bool
# Indicates if this agent is marked as favorite by the user
is_favorite: bool
@staticmethod
def from_db(
agent: prisma.models.LibraryAgent,
@@ -130,6 +133,7 @@ class LibraryAgent(pydantic.BaseModel):
new_output=new_output,
can_access_graph=can_access_graph,
is_latest_version=is_latest_version,
is_favorite=agent.isFavorite,
)
@@ -314,6 +318,7 @@ class LibraryAgentSort(str, Enum):
CREATED_AT = "createdAt"
UPDATED_AT = "updatedAt"
FAVORITES_FIRST = "favoritesFirst"
class LibraryAgentUpdateRequest(pydantic.BaseModel):

View File

@@ -3,7 +3,7 @@
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Button } from "@/components/atoms/Button/Button";
import { useState } from "react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgent } from "@/lib/autogpt-server-api/types";
import { useAgentRunModal } from "./useAgentRunModal";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { AgentCostSection } from "./components/AgentCostSection/AgentCostSection";

View File

@@ -1,4 +1,4 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgent } from "@/lib/autogpt-server-api/types";
import { Text } from "@/components/atoms/Text/Text";
import { Badge } from "@/components/atoms/Badge/Badge";
import { formatDate } from "@/lib/utils/time";

View File

@@ -1,5 +1,5 @@
import { Input } from "@/components/atoms/Input/Input";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgent } from "@/lib/autogpt-server-api/types";
interface Props {
agent: LibraryAgent;

View File

@@ -1,5 +1,5 @@
import { Badge } from "@/components/atoms/Badge/Badge";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgent } from "@/lib/autogpt-server-api/types";
import { Text } from "@/components/atoms/Text/Text";
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";

View File

@@ -1,7 +1,7 @@
"use client";
import React, { createContext, useContext } from "react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgent } from "@/lib/autogpt-server-api/types";
import { RunVariant } from "./useAgentRunModal";
export interface RunAgentModalContextValue {

View File

@@ -1,4 +1,4 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgent } from "@/lib/autogpt-server-api/types";
import { useState, useCallback, useMemo } from "react";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
@@ -7,7 +7,7 @@ import { usePostV1CreateExecutionSchedule as useCreateSchedule } from "@/app/api
import { usePostV2SetupTrigger } from "@/app/api/__generated__/endpoints/presets/presets";
import { ExecuteGraphResponse } from "@/app/api/__generated__/models/executeGraphResponse";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { LibraryAgentPreset } from "@/lib/autogpt-server-api/types";
export type RunVariant =
| "manual"

View File

@@ -1,10 +1,17 @@
import { useGetV2GetLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
import { useQuery } from "@tanstack/react-query";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useParams } from "next/navigation";
export function useAgentRunsView() {
const { id } = useParams();
const agentId = id as string;
const { data: response, isSuccess, error } = useGetV2GetLibraryAgent(agentId);
const api = useBackendAPI();
const { data: response, isSuccess, error } = useQuery({
queryKey: ["v2", "get", "library", "agent", agentId],
queryFn: () => api.getLibraryAgent(agentId),
enabled: !!agentId,
});
return {
agentId: id,

View File

@@ -1,8 +1,11 @@
import Link from "next/link";
import Image from "next/image";
import { Heart } from "@phosphor-icons/react";
import { useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgent } from "@/lib/autogpt-server-api/types";
import { useFavoriteAgent } from "./useFavoriteAgent";
interface LibraryAgentCardProps {
agent: LibraryAgent;
@@ -17,8 +20,23 @@ export default function LibraryAgentCard({
can_access_graph,
creator_image_url,
image_url,
is_favorite,
},
}: LibraryAgentCardProps) {
const [isFavorite, setIsFavorite] = useState(is_favorite);
const { toggleFavorite } = useFavoriteAgent();
const handleFavoriteClick = async (e: React.MouseEvent) => {
e.preventDefault(); // Prevent navigation to agent page
e.stopPropagation();
try {
const newFavoriteStatus = await toggleFavorite(id, isFavorite);
setIsFavorite(newFavoriteStatus);
} catch (error) {
// Error is already handled in the hook
}
};
return (
<div
data-testid="library-agent-card"
@@ -67,6 +85,19 @@ export default function LibraryAgentCard({
<AvatarFallback size={64}>{name.charAt(0)}</AvatarFallback>
</Avatar>
</div>
{/* Favorite heart icon */}
<button
onClick={handleFavoriteClick}
className="absolute top-4 right-4 rounded-full bg-white/90 p-2 shadow-sm transition-all duration-200 hover:bg-white hover:scale-110 dark:bg-gray-800/90 dark:hover:bg-gray-700"
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
<Heart
size={20}
weight={isFavorite ? "fill" : "regular"}
className={isFavorite ? "text-red-500" : "text-gray-400 hover:text-red-500"}
/>
</button>
</Link>
<div className="flex w-full flex-1 flex-col px-4 py-4">

View File

@@ -0,0 +1,103 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { LibraryAgentID } from "@/lib/autogpt-server-api/types";
export function useFavoriteAgent() {
const api = useBackendAPI();
const { toast } = useToast();
const queryClient = useQueryClient();
const toggleFavorite = useCallback(
async (agentId: LibraryAgentID, currentFavoriteStatus: boolean) => {
const newFavoriteStatus = !currentFavoriteStatus;
// Optimistic update - update the cache immediately for all library agent queries
queryClient.setQueriesData(
{
predicate: (query) => query.queryKey[0] === "v2" &&
query.queryKey[1] === "list" &&
query.queryKey[2] === "library" &&
query.queryKey[3] === "agents",
},
(oldData: any) => {
if (!oldData?.pages) return oldData;
return {
...oldData,
pages: oldData.pages.map((page: any) => ({
...page,
agents: page.agents.map((agent: any) =>
agent.id === agentId
? { ...agent, is_favorite: newFavoriteStatus }
: agent
),
})),
};
}
);
try {
await api.updateLibraryAgent(agentId, {
is_favorite: newFavoriteStatus,
});
toast({
title: newFavoriteStatus ? "Added to favorites" : "Removed from favorites",
description: newFavoriteStatus
? "Agent has been added to your favorites"
: "Agent has been removed from your favorites",
duration: 2000,
});
// Invalidate the library agents query to refresh the list with proper sorting
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === "v2" &&
query.queryKey[1] === "list" &&
query.queryKey[2] === "library" &&
query.queryKey[3] === "agents",
});
return newFavoriteStatus;
} catch (error) {
// Revert optimistic update on error for all library agent queries
queryClient.setQueriesData(
{
predicate: (query) => query.queryKey[0] === "v2" &&
query.queryKey[1] === "list" &&
query.queryKey[2] === "library" &&
query.queryKey[3] === "agents",
},
(oldData: any) => {
if (!oldData?.pages) return oldData;
return {
...oldData,
pages: oldData.pages.map((page: any) => ({
...page,
agents: page.agents.map((agent: any) =>
agent.id === agentId
? { ...agent, is_favorite: currentFavoriteStatus }
: agent
),
})),
};
}
);
console.error("Failed to update favorite status:", error);
toast({
title: "Error",
description: "Failed to update favorite status. Please try again.",
duration: 3000,
variant: "destructive",
});
throw error;
}
},
[api, toast, queryClient]
);
return { toggleFavorite };
}

View File

@@ -1,44 +1,46 @@
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgentResponse } from "@/app/api/__generated__/models/libraryAgentResponse";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { LibraryAgentResponse } from "@/lib/autogpt-server-api/types";
import { useLibraryPageContext } from "../state-provider";
export const useLibraryAgentList = () => {
const { searchTerm, librarySort } = useLibraryPageContext();
const api = useBackendAPI();
const {
data: agents,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: agentLoading,
} = useGetV2ListLibraryAgentsInfinite(
{
page: 1,
page_size: 8,
search_term: searchTerm || undefined,
sort_by: librarySort,
} = useInfiniteQuery({
queryKey: ["v2", "list", "library", "agents", searchTerm, librarySort],
queryFn: async ({ pageParam = 1 }) => {
return await api.listLibraryAgents({
page: pageParam,
page_size: 8,
search_term: searchTerm || undefined,
sort_by: librarySort,
});
},
{
query: {
getNextPageParam: (lastPage) => {
const pagination = (lastPage.data as LibraryAgentResponse).pagination;
const isMore =
pagination.current_page * pagination.page_size <
pagination.total_items;
getNextPageParam: (lastPage) => {
const pagination = lastPage.pagination;
const isMore =
pagination.current_page * pagination.page_size <
pagination.total_items;
return isMore ? pagination.current_page + 1 : undefined;
},
},
return isMore ? pagination.current_page + 1 : undefined;
},
);
initialPageParam: 1,
});
const allAgents =
agents?.pages?.flatMap((page) => {
const response = page.data as LibraryAgentResponse;
return response.agents;
return page.agents;
}) ?? [];
const agentCount = agents?.pages?.[0]
? (agents.pages[0].data as LibraryAgentResponse).pagination.total_items
? agents.pages[0].pagination.total_items
: 0;
return {

View File

@@ -8,7 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { LibraryAgentSortEnum as LibraryAgentSort } from "@/lib/autogpt-server-api/types";
import { useLibrarySortMenu } from "./useLibrarySortMenu";
export default function LibrarySortMenu(): React.ReactNode {
@@ -19,14 +19,17 @@ export default function LibrarySortMenu(): React.ReactNode {
<Select onValueChange={handleSortChange}>
<SelectTrigger className="ml-1 w-fit space-x-1 border-none px-0 text-base underline underline-offset-4 shadow-none">
<ArrowDownNarrowWideIcon className="h-4 w-4 sm:hidden" />
<SelectValue placeholder="Last Modified" />
<SelectValue placeholder="Favorites First" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={LibraryAgentSort.createdAt}>
<SelectItem value={LibraryAgentSort.FAVORITES_FIRST}>
Favorites First
</SelectItem>
<SelectItem value={LibraryAgentSort.CREATED_AT}>
Creation Date
</SelectItem>
<SelectItem value={LibraryAgentSort.updatedAt}>
<SelectItem value={LibraryAgentSort.UPDATED_AT}>
Last Modified
</SelectItem>
</SelectGroup>

View File

@@ -1,4 +1,4 @@
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { LibraryAgentSortEnum as LibraryAgentSort } from "@/lib/autogpt-server-api/types";
import { useLibraryPageContext } from "../state-provider";
export const useLibrarySortMenu = () => {
@@ -11,12 +11,14 @@ export const useLibrarySortMenu = () => {
const getSortLabel = (sort: LibraryAgentSort) => {
switch (sort) {
case LibraryAgentSort.createdAt:
case LibraryAgentSort.CREATED_AT:
return "Creation Date";
case LibraryAgentSort.updatedAt:
case LibraryAgentSort.UPDATED_AT:
return "Last Modified";
case LibraryAgentSort.FAVORITES_FIRST:
return "Favorites First";
default:
return "Last Modified";
return "Favorites First";
}
};

View File

@@ -1,6 +1,6 @@
"use client";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { LibraryAgentSortEnum as LibraryAgentSort } from "@/lib/autogpt-server-api/types";
import {
createContext,
useState,
@@ -31,7 +31,7 @@ export function LibraryPageStateProvider({
const [searchTerm, setSearchTerm] = useState<string>("");
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [librarySort, setLibrarySort] = useState<LibraryAgentSort>(
LibraryAgentSort.updatedAt,
LibraryAgentSort.FAVORITES_FIRST,
);
return (

View File

@@ -428,6 +428,7 @@ export type LibraryAgent = {
new_output: boolean;
can_access_graph: boolean;
is_latest_version: boolean;
is_favorite: boolean;
} & (
| {
has_external_trigger: true;
@@ -502,6 +503,7 @@ export type LibraryAgentPresetUpdatable = Partial<
export enum LibraryAgentSortEnum {
CREATED_AT = "createdAt",
UPDATED_AT = "updatedAt",
FAVORITES_FIRST = "favoritesFirst",
}
/* *** CREDENTIALS *** */