diff --git a/autogpt_platform/backend/backend/server/v2/builder/db.py b/autogpt_platform/backend/backend/server/v2/builder/db.py index c3f6ac88ab..9856d53c0e 100644 --- a/autogpt_platform/backend/backend/server/v2/builder/db.py +++ b/autogpt_platform/backend/backend/server/v2/builder/db.py @@ -1,9 +1,16 @@ import logging +from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from difflib import SequenceMatcher +from typing import Sequence import prisma import backend.data.block +import backend.server.v2.library.db as library_db +import backend.server.v2.library.model as library_model +import backend.server.v2.store.db as store_db +import backend.server.v2.store.model as store_model from backend.blocks import load_all_blocks from backend.blocks.llm import LlmModel from backend.data.block import AnyBlockSchema, BlockCategory, BlockInfo, BlockSchema @@ -14,17 +21,36 @@ from backend.server.v2.builder.model import ( BlockResponse, BlockType, CountResponse, + FilterType, Provider, ProviderResponse, - SearchBlocksResponse, + SearchEntry, ) from backend.util.cache import cached from backend.util.models import Pagination logger = logging.getLogger(__name__) llm_models = [name.name.lower().replace("_", " ") for name in LlmModel] -_static_counts_cache: dict | None = None -_suggested_blocks: list[BlockInfo] | None = None + +MAX_LIBRARY_AGENT_RESULTS = 100 +MAX_MARKETPLACE_AGENT_RESULTS = 100 +MIN_SCORE_FOR_FILTERED_RESULTS = 10.0 + +SearchResultItem = BlockInfo | library_model.LibraryAgent | store_model.StoreAgent + + +@dataclass +class _ScoredItem: + item: SearchResultItem + filter_type: FilterType + score: float + sort_key: str + + +@dataclass +class _SearchCacheEntry: + items: list[SearchResultItem] + total_items: dict[FilterType, int] def get_block_categories(category_blocks: int = 3) -> list[BlockCategoryResponse]: @@ -130,71 +156,244 @@ def get_block_by_id(block_id: str) -> BlockInfo | None: return None -def search_blocks( - include_blocks: bool = True, - include_integrations: bool = True, - query: str = "", - page: int = 1, - page_size: int = 50, -) -> SearchBlocksResponse: +async def update_search(user_id: str, search: SearchEntry) -> str: """ - Get blocks based on the filter and query. - `providers` only applies for `integrations` filter. + Upsert a search request for the user and return the search ID. """ - blocks: list[AnyBlockSchema] = [] - query = query.lower() + if search.search_id: + # Update existing search + await prisma.models.BuilderSearchHistory.prisma().update( + where={ + "id": search.search_id, + }, + data={ + "searchQuery": search.search_query or "", + "filter": search.filter or [], # type: ignore + "byCreator": search.by_creator or [], + }, + ) + return search.search_id + else: + # Create new search + new_search = await prisma.models.BuilderSearchHistory.prisma().create( + data={ + "userId": user_id, + "searchQuery": search.search_query or "", + "filter": search.filter or [], # type: ignore + "byCreator": search.by_creator or [], + } + ) + return new_search.id - total = 0 - skip = (page - 1) * page_size - take = page_size + +async def get_recent_searches(user_id: str, limit: int = 5) -> list[SearchEntry]: + """ + Get the user's most recent search requests. + """ + searches = await prisma.models.BuilderSearchHistory.prisma().find_many( + where={ + "userId": user_id, + }, + order={ + "updatedAt": "desc", + }, + take=limit, + ) + return [ + SearchEntry( + search_query=s.searchQuery, + filter=s.filter, # type: ignore + by_creator=s.byCreator, + search_id=s.id, + ) + for s in searches + ] + + +async def get_sorted_search_results( + *, + user_id: str, + search_query: str | None, + filters: Sequence[FilterType], + by_creator: Sequence[str] | None = None, +) -> _SearchCacheEntry: + normalized_filters: tuple[FilterType, ...] = tuple(sorted(set(filters or []))) + normalized_creators: tuple[str, ...] = tuple(sorted(set(by_creator or []))) + return await _build_cached_search_results( + user_id=user_id, + search_query=search_query or "", + filters=normalized_filters, + by_creator=normalized_creators, + ) + + +@cached(ttl_seconds=300, shared_cache=True) +async def _build_cached_search_results( + user_id: str, + search_query: str, + filters: tuple[FilterType, ...], + by_creator: tuple[str, ...], +) -> _SearchCacheEntry: + normalized_query = (search_query or "").strip().lower() + + include_blocks = "blocks" in filters + include_integrations = "integrations" in filters + include_library_agents = "my_agents" in filters + include_marketplace_agents = "marketplace_agents" in filters + + scored_items: list[_ScoredItem] = [] + total_items: dict[FilterType, int] = { + "blocks": 0, + "integrations": 0, + "marketplace_agents": 0, + "my_agents": 0, + } + + block_results, block_total, integration_total = _collect_block_results( + normalized_query=normalized_query, + include_blocks=include_blocks, + include_integrations=include_integrations, + ) + scored_items.extend(block_results) + total_items["blocks"] = block_total + total_items["integrations"] = integration_total + + if include_library_agents: + library_response = await library_db.list_library_agents( + user_id=user_id, + search_term=search_query or None, + page=1, + page_size=MAX_LIBRARY_AGENT_RESULTS, + ) + total_items["my_agents"] = library_response.pagination.total_items + scored_items.extend( + _build_library_items( + agents=library_response.agents, + normalized_query=normalized_query, + ) + ) + + if include_marketplace_agents: + marketplace_response = await store_db.get_store_agents( + creators=list(by_creator) or None, + search_query=search_query or None, + page=1, + page_size=MAX_MARKETPLACE_AGENT_RESULTS, + ) + total_items["marketplace_agents"] = marketplace_response.pagination.total_items + scored_items.extend( + _build_marketplace_items( + agents=marketplace_response.agents, + normalized_query=normalized_query, + ) + ) + + sorted_items = sorted( + scored_items, + key=lambda entry: (-entry.score, entry.sort_key, entry.filter_type), + ) + + return _SearchCacheEntry( + items=[entry.item for entry in sorted_items], + total_items=total_items, + ) + + +def _collect_block_results( + *, + normalized_query: str, + include_blocks: bool, + include_integrations: bool, +) -> tuple[list[_ScoredItem], int, int]: + results: list[_ScoredItem] = [] block_count = 0 integration_count = 0 + if not include_blocks and not include_integrations: + return results, block_count, integration_count + for block_type in load_all_blocks().values(): block: AnyBlockSchema = block_type() - # Skip disabled blocks if block.disabled: continue - # Skip blocks that don't match the query - if ( - query not in block.name.lower() - and query not in block.description.lower() - and not _matches_llm_model(block.input_schema, query) - ): - continue - keep = False + + block_info = block.get_info() credentials = list(block.input_schema.get_credentials_fields().values()) - if include_integrations and len(credentials) > 0: - keep = True + is_integration = len(credentials) > 0 + + if is_integration and not include_integrations: + continue + if not is_integration and not include_blocks: + continue + + score = _score_block(block, block_info, normalized_query) + if not _should_include_item(score, normalized_query): + continue + + filter_type: FilterType = "integrations" if is_integration else "blocks" + if is_integration: integration_count += 1 - if include_blocks and len(credentials) == 0: - keep = True + else: block_count += 1 - if not keep: + results.append( + _ScoredItem( + item=block_info, + filter_type=filter_type, + score=score, + sort_key=_get_item_name(block_info), + ) + ) + + return results, block_count, integration_count + + +def _build_library_items( + *, + agents: list[library_model.LibraryAgent], + normalized_query: str, +) -> list[_ScoredItem]: + results: list[_ScoredItem] = [] + + for agent in agents: + score = _score_library_agent(agent, normalized_query) + if not _should_include_item(score, normalized_query): continue - total += 1 - if skip > 0: - skip -= 1 - continue - if take > 0: - take -= 1 - blocks.append(block) + results.append( + _ScoredItem( + item=agent, + filter_type="my_agents", + score=score, + sort_key=_get_item_name(agent), + ) + ) - return SearchBlocksResponse( - blocks=BlockResponse( - blocks=[b.get_info() for b in blocks], - pagination=Pagination( - total_items=total, - total_pages=(total + page_size - 1) // page_size, - current_page=page, - page_size=page_size, - ), - ), - total_block_count=block_count, - total_integration_count=integration_count, - ) + return results + + +def _build_marketplace_items( + *, + agents: list[store_model.StoreAgent], + normalized_query: str, +) -> list[_ScoredItem]: + results: list[_ScoredItem] = [] + + for agent in agents: + score = _score_store_agent(agent, normalized_query) + if not _should_include_item(score, normalized_query): + continue + + results.append( + _ScoredItem( + item=agent, + filter_type="marketplace_agents", + score=score, + sort_key=_get_item_name(agent), + ) + ) + + return results def get_providers( @@ -251,16 +450,12 @@ async def get_counts(user_id: str) -> CountResponse: ) +@cached(ttl_seconds=3600) async def _get_static_counts(): """ Get counts of blocks, integrations, and marketplace agents. This is cached to avoid unnecessary database queries and calculations. - Can't use functools.cache here because the function is async. """ - global _static_counts_cache - if _static_counts_cache is not None: - return _static_counts_cache - all_blocks = 0 input_blocks = 0 action_blocks = 0 @@ -287,7 +482,7 @@ async def _get_static_counts(): marketplace_agents = await prisma.models.StoreAgent.prisma().count() - _static_counts_cache = { + return { "all_blocks": all_blocks, "input_blocks": input_blocks, "action_blocks": action_blocks, @@ -296,8 +491,6 @@ async def _get_static_counts(): "marketplace_agents": marketplace_agents, } - return _static_counts_cache - def _matches_llm_model(schema_cls: type[BlockSchema], query: str) -> bool: for field in schema_cls.model_fields.values(): @@ -308,6 +501,123 @@ def _matches_llm_model(schema_cls: type[BlockSchema], query: str) -> bool: return False +def _score_block( + block: AnyBlockSchema, + block_info: BlockInfo, + normalized_query: str, +) -> float: + if not normalized_query: + return 0.0 + + name = block_info.name.lower() + description = block_info.description.lower() + score = _score_primary_fields(name, description, normalized_query) + + category_text = " ".join( + category.get("category", "").lower() for category in block_info.categories + ) + score += _score_additional_field(category_text, normalized_query, 12, 6) + + credentials_info = block.input_schema.get_credentials_fields_info().values() + provider_names = [ + provider.value.lower() + for info in credentials_info + for provider in info.provider + ] + provider_text = " ".join(provider_names) + score += _score_additional_field(provider_text, normalized_query, 15, 6) + + if _matches_llm_model(block.input_schema, normalized_query): + score += 20 + + return score + + +def _score_library_agent( + agent: library_model.LibraryAgent, + normalized_query: str, +) -> float: + if not normalized_query: + return 0.0 + + name = agent.name.lower() + description = (agent.description or "").lower() + instructions = (agent.instructions or "").lower() + + score = _score_primary_fields(name, description, normalized_query) + score += _score_additional_field(instructions, normalized_query, 15, 6) + score += _score_additional_field( + agent.creator_name.lower(), normalized_query, 10, 5 + ) + + return score + + +def _score_store_agent( + agent: store_model.StoreAgent, + normalized_query: str, +) -> float: + if not normalized_query: + return 0.0 + + name = agent.agent_name.lower() + description = agent.description.lower() + sub_heading = agent.sub_heading.lower() + + score = _score_primary_fields(name, description, normalized_query) + score += _score_additional_field(sub_heading, normalized_query, 12, 6) + score += _score_additional_field(agent.creator.lower(), normalized_query, 10, 5) + + return score + + +def _score_primary_fields(name: str, description: str, query: str) -> float: + score = 0.0 + if name == query: + score += 120 + elif name.startswith(query): + score += 90 + elif query in name: + score += 60 + + score += SequenceMatcher(None, name, query).ratio() * 50 + if description: + if query in description: + score += 30 + score += SequenceMatcher(None, description, query).ratio() * 25 + return score + + +def _score_additional_field( + value: str, + query: str, + contains_weight: float, + similarity_weight: float, +) -> float: + if not value or not query: + return 0.0 + + score = 0.0 + if query in value: + score += contains_weight + score += SequenceMatcher(None, value, query).ratio() * similarity_weight + return score + + +def _should_include_item(score: float, normalized_query: str) -> bool: + if not normalized_query: + return True + return score >= MIN_SCORE_FOR_FILTERED_RESULTS + + +def _get_item_name(item: SearchResultItem) -> str: + if isinstance(item, BlockInfo): + return item.name.lower() + if isinstance(item, library_model.LibraryAgent): + return item.name.lower() + return item.agent_name.lower() + + @cached(ttl_seconds=3600) def _get_all_providers() -> dict[ProviderName, Provider]: providers: dict[ProviderName, Provider] = {} @@ -329,13 +639,9 @@ def _get_all_providers() -> dict[ProviderName, Provider]: return providers +@cached(ttl_seconds=3600) async def get_suggested_blocks(count: int = 5) -> list[BlockInfo]: - global _suggested_blocks - - if _suggested_blocks is not None and len(_suggested_blocks) >= count: - return _suggested_blocks[:count] - - _suggested_blocks = [] + suggested_blocks = [] # Sum the number of executions for each block type # Prisma cannot group by nested relations, so we do a raw query # Calculate the cutoff timestamp @@ -376,7 +682,7 @@ async def get_suggested_blocks(count: int = 5) -> list[BlockInfo]: # Sort blocks by execution count blocks.sort(key=lambda x: x[1], reverse=True) - _suggested_blocks = [block[0] for block in blocks] + suggested_blocks = [block[0] for block in blocks] # Return the top blocks - return _suggested_blocks[:count] + return suggested_blocks[:count] diff --git a/autogpt_platform/backend/backend/server/v2/builder/model.py b/autogpt_platform/backend/backend/server/v2/builder/model.py index e1a7e744fd..4a1de595d1 100644 --- a/autogpt_platform/backend/backend/server/v2/builder/model.py +++ b/autogpt_platform/backend/backend/server/v2/builder/model.py @@ -18,10 +18,17 @@ FilterType = Literal[ BlockType = Literal["all", "input", "action", "output"] +class SearchEntry(BaseModel): + search_query: str | None = None + filter: list[FilterType] | None = None + by_creator: list[str] | None = None + search_id: str | None = None + + # Suggestions class SuggestionsResponse(BaseModel): otto_suggestions: list[str] - recent_searches: list[str] + recent_searches: list[SearchEntry] providers: list[ProviderName] top_blocks: list[BlockInfo] @@ -32,7 +39,7 @@ class BlockCategoryResponse(BaseModel): total_blocks: int blocks: list[BlockInfo] - model_config = {"use_enum_values": False} # <== use enum names like "AI" + model_config = {"use_enum_values": False} # Use enum names like "AI" # Input/Action/Output and see all for block categories @@ -53,17 +60,11 @@ class ProviderResponse(BaseModel): pagination: Pagination -class SearchBlocksResponse(BaseModel): - blocks: BlockResponse - total_block_count: int - total_integration_count: int - - class SearchResponse(BaseModel): items: list[BlockInfo | library_model.LibraryAgent | store_model.StoreAgent] + search_id: str total_items: dict[FilterType, int] - page: int - more_pages: bool + pagination: Pagination class CountResponse(BaseModel): diff --git a/autogpt_platform/backend/backend/server/v2/builder/routes.py b/autogpt_platform/backend/backend/server/v2/builder/routes.py index ebc9fd5baf..b87bf8ca1a 100644 --- a/autogpt_platform/backend/backend/server/v2/builder/routes.py +++ b/autogpt_platform/backend/backend/server/v2/builder/routes.py @@ -6,10 +6,6 @@ from autogpt_libs.auth.dependencies import get_user_id, requires_user import backend.server.v2.builder.db as builder_db import backend.server.v2.builder.model as builder_model -import backend.server.v2.library.db as library_db -import backend.server.v2.library.model as library_model -import backend.server.v2.store.db as store_db -import backend.server.v2.store.model as store_model from backend.integrations.providers import ProviderName from backend.util.models import Pagination @@ -45,7 +41,9 @@ def sanitize_query(query: str | None) -> str | None: summary="Get Builder suggestions", response_model=builder_model.SuggestionsResponse, ) -async def get_suggestions() -> builder_model.SuggestionsResponse: +async def get_suggestions( + user_id: Annotated[str, fastapi.Security(get_user_id)], +) -> builder_model.SuggestionsResponse: """ Get all suggestions for the Blocks Menu. """ @@ -55,11 +53,7 @@ async def get_suggestions() -> builder_model.SuggestionsResponse: "Help me create a list", "Help me feed my data to Google Maps", ], - recent_searches=[ - "image generation", - "deepfake", - "competitor analysis", - ], + recent_searches=await builder_db.get_recent_searches(user_id), providers=[ ProviderName.TWITTER, ProviderName.GITHUB, @@ -147,7 +141,6 @@ async def get_providers( ) -# Not using post method because on frontend, orval doesn't support Infinite Query with POST method. @router.get( "/search", summary="Builder search", @@ -157,7 +150,7 @@ async def get_providers( async def search( user_id: Annotated[str, fastapi.Security(get_user_id)], search_query: Annotated[str | None, fastapi.Query()] = None, - filter: Annotated[list[str] | None, fastapi.Query()] = None, + filter: Annotated[list[builder_model.FilterType] | None, fastapi.Query()] = None, search_id: Annotated[str | None, fastapi.Query()] = None, by_creator: Annotated[list[str] | None, fastapi.Query()] = None, page: Annotated[int, fastapi.Query()] = 1, @@ -176,69 +169,43 @@ async def search( ] search_query = sanitize_query(search_query) - # Blocks&Integrations - blocks = builder_model.SearchBlocksResponse( - blocks=builder_model.BlockResponse( - blocks=[], - pagination=Pagination.empty(), - ), - total_block_count=0, - total_integration_count=0, + # Get all possible results + cached_results = await builder_db.get_sorted_search_results( + user_id=user_id, + search_query=search_query, + filters=filter, + by_creator=by_creator, ) - if "blocks" in filter or "integrations" in filter: - blocks = builder_db.search_blocks( - include_blocks="blocks" in filter, - include_integrations="integrations" in filter, - query=search_query or "", - page=page, - page_size=page_size, - ) - # Library Agents - my_agents = library_model.LibraryAgentResponse( - agents=[], - pagination=Pagination.empty(), + # Paginate results + total_combined_items = len(cached_results.items) + pagination = Pagination( + total_items=total_combined_items, + total_pages=(total_combined_items + page_size - 1) // page_size, + current_page=page, + page_size=page_size, ) - if "my_agents" in filter: - my_agents = await library_db.list_library_agents( - user_id=user_id, - search_term=search_query, - page=page, - page_size=page_size, - ) - # Marketplace Agents - marketplace_agents = store_model.StoreAgentsResponse( - agents=[], - pagination=Pagination.empty(), - ) - if "marketplace_agents" in filter: - marketplace_agents = await store_db.get_store_agents( - creators=by_creator, + start_idx = (page - 1) * page_size + end_idx = start_idx + page_size + paginated_items = cached_results.items[start_idx:end_idx] + + # Update the search entry by id + search_id = await builder_db.update_search( + user_id, + builder_model.SearchEntry( search_query=search_query, - page=page, - page_size=page_size, - ) - - more_pages = False - if ( - blocks.blocks.pagination.current_page < blocks.blocks.pagination.total_pages - or my_agents.pagination.current_page < my_agents.pagination.total_pages - or marketplace_agents.pagination.current_page - < marketplace_agents.pagination.total_pages - ): - more_pages = True + filter=filter, + by_creator=by_creator, + search_id=search_id, + ), + ) return builder_model.SearchResponse( - items=blocks.blocks.blocks + my_agents.agents + marketplace_agents.agents, - total_items={ - "blocks": blocks.total_block_count, - "integrations": blocks.total_integration_count, - "marketplace_agents": marketplace_agents.pagination.total_items, - "my_agents": my_agents.pagination.total_items, - }, - page=page, - more_pages=more_pages, + items=paginated_items, + search_id=search_id, + total_items=cached_results.total_items, + pagination=pagination, ) diff --git a/autogpt_platform/backend/migrations/20251209182537_add_builder_search/migration.sql b/autogpt_platform/backend/migrations/20251209182537_add_builder_search/migration.sql new file mode 100644 index 0000000000..8b9786e47c --- /dev/null +++ b/autogpt_platform/backend/migrations/20251209182537_add_builder_search/migration.sql @@ -0,0 +1,15 @@ +-- Create BuilderSearchHistory table +CREATE TABLE "BuilderSearchHistory" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "searchQuery" TEXT NOT NULL, + "filter" TEXT[] DEFAULT ARRAY[]::TEXT[], + "byCreator" TEXT[] DEFAULT ARRAY[]::TEXT[], + + CONSTRAINT "BuilderSearchHistory_pkey" PRIMARY KEY ("id") +); + +-- Define User foreign relation +ALTER TABLE "BuilderSearchHistory" ADD CONSTRAINT "BuilderSearchHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index c54b014471..121ccab5fc 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -53,6 +53,7 @@ model User { Profile Profile[] UserOnboarding UserOnboarding? + BuilderSearchHistory BuilderSearchHistory[] StoreListings StoreListing[] StoreListingReviews StoreListingReview[] StoreVersionsReviewed StoreListingVersion[] @@ -114,6 +115,19 @@ model UserOnboarding { User User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model BuilderSearchHistory { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + searchQuery String + filter String[] @default([]) + byCreator String[] @default([]) + + userId String + User User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + // This model describes the Agent Graph/Flow (Multi Agent System). model AgentGraph { id String @default(uuid()) diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts index bff61f2d85..5e9007e617 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts @@ -1,7 +1,7 @@ import { useBlockMenuStore } from "../../../../stores/blockMenuStore"; import { useGetV2BuilderSearchInfinite } from "@/app/api/__generated__/endpoints/store/store"; import { SearchResponse } from "@/app/api/__generated__/models/searchResponse"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useAddAgentToBuilder } from "../hooks/useAddAgentToBuilder"; import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import { getV2GetSpecificAgent } from "@/app/api/__generated__/endpoints/store/store"; @@ -9,16 +9,27 @@ import { getGetV2ListLibraryAgentsQueryKey, usePostV2AddMarketplaceAgent, } from "@/app/api/__generated__/endpoints/library/library"; -import { getGetV2GetBuilderItemCountsQueryKey } from "@/app/api/__generated__/endpoints/default/default"; +import { + getGetV2GetBuilderItemCountsQueryKey, + getGetV2GetBuilderSuggestionsQueryKey, +} from "@/app/api/__generated__/endpoints/default/default"; import { getQueryClient } from "@/lib/react-query/queryClient"; import { useToast } from "@/components/molecules/Toast/use-toast"; import * as Sentry from "@sentry/nextjs"; export const useBlockMenuSearch = () => { - const { searchQuery } = useBlockMenuStore(); + const { searchQuery, searchId, setSearchId } = useBlockMenuStore(); const { toast } = useToast(); const { addAgentToBuilder, addLibraryAgentToBuilder } = useAddAgentToBuilder(); + const queryClient = getQueryClient(); + + const resetSearchSession = useCallback(() => { + setSearchId(undefined); + queryClient.invalidateQueries({ + queryKey: getGetV2GetBuilderSuggestionsQueryKey(), + }); + }, [queryClient, setSearchId]); const [addingLibraryAgentId, setAddingLibraryAgentId] = useState< string | null @@ -38,13 +49,19 @@ export const useBlockMenuSearch = () => { page: 1, page_size: 8, search_query: searchQuery, + search_id: searchId, }, { query: { - getNextPageParam: (lastPage, allPages) => { - const pagination = lastPage.data as SearchResponse; - const isMore = pagination.more_pages; - return isMore ? allPages.length + 1 : undefined; + getNextPageParam: (lastPage) => { + const response = lastPage.data as SearchResponse; + const { pagination } = response; + if (!pagination) { + return undefined; + } + + const { current_page, total_pages } = pagination; + return current_page < total_pages ? current_page + 1 : undefined; }, }, }, @@ -53,7 +70,6 @@ export const useBlockMenuSearch = () => { const { mutateAsync: addMarketplaceAgent } = usePostV2AddMarketplaceAgent({ mutation: { onSuccess: () => { - const queryClient = getQueryClient(); queryClient.invalidateQueries({ queryKey: getGetV2ListLibraryAgentsQueryKey(), }); @@ -75,6 +91,24 @@ export const useBlockMenuSearch = () => { }, }); + useEffect(() => { + if (!searchData?.pages?.length) { + return; + } + + const latestPage = searchData.pages[searchData.pages.length - 1]; + const response = latestPage?.data as SearchResponse; + if (response?.search_id && response.search_id !== searchId) { + setSearchId(response.search_id); + } + }, [searchData, searchId, setSearchId]); + + useEffect(() => { + if (searchId && !searchQuery) { + resetSearchSession(); + } + }, [resetSearchSession, searchId, searchQuery]); + const allSearchData = searchData?.pages?.flatMap((page) => { const response = page.data as SearchResponse; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/useBlockMenuSearchBar.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/useBlockMenuSearchBar.ts index b55a638e08..ab1af16584 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/useBlockMenuSearchBar.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/useBlockMenuSearchBar.ts @@ -1,30 +1,32 @@ import { debounce } from "lodash"; import { useCallback, useEffect, useRef, useState } from "react"; import { useBlockMenuStore } from "../../../../stores/blockMenuStore"; +import { getQueryClient } from "@/lib/react-query/queryClient"; +import { getGetV2GetBuilderSuggestionsQueryKey } from "@/app/api/__generated__/endpoints/default/default"; const SEARCH_DEBOUNCE_MS = 300; export const useBlockMenuSearchBar = () => { const inputRef = useRef(null); const [localQuery, setLocalQuery] = useState(""); - const { setSearchQuery, setSearchId, searchId, searchQuery } = - useBlockMenuStore(); + const { setSearchQuery, setSearchId, searchQuery } = useBlockMenuStore(); + const queryClient = getQueryClient(); - const searchIdRef = useRef(searchId); - useEffect(() => { - searchIdRef.current = searchId; - }, [searchId]); + const clearSearchSession = useCallback(() => { + setSearchId(undefined); + queryClient.invalidateQueries({ + queryKey: getGetV2GetBuilderSuggestionsQueryKey(), + }); + }, [queryClient, setSearchId]); const debouncedSetSearchQuery = useCallback( debounce((value: string) => { setSearchQuery(value); if (value.length === 0) { - setSearchId(undefined); - } else if (!searchIdRef.current) { - setSearchId(crypto.randomUUID()); + clearSearchSession(); } }, SEARCH_DEBOUNCE_MS), - [setSearchQuery, setSearchId], + [clearSearchSession, setSearchQuery], ); useEffect(() => { @@ -36,13 +38,13 @@ export const useBlockMenuSearchBar = () => { const handleClear = () => { setLocalQuery(""); setSearchQuery(""); - setSearchId(undefined); + clearSearchSession(); debouncedSetSearchQuery.cancel(); }; useEffect(() => { setLocalQuery(searchQuery); - }, []); + }, [searchQuery]); return { handleClear, diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/HorizontalScroll.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/HorizontalScroll.tsx new file mode 100644 index 0000000000..0f953394e6 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/HorizontalScroll.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useRef, useState } from "react"; +import { ArrowLeftIcon, ArrowRightIcon } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; + +interface HorizontalScrollAreaProps { + children: React.ReactNode; + wrapperClassName?: string; + scrollContainerClassName?: string; + scrollAmount?: number; + dependencyList?: React.DependencyList; +} + +const defaultDependencies: React.DependencyList = []; +const baseScrollClasses = + "flex gap-2 overflow-x-auto px-8 [scrollbar-width:none] [-ms-overflow-style:'none'] [&::-webkit-scrollbar]:hidden"; + +export const HorizontalScroll: React.FC = ({ + children, + wrapperClassName, + scrollContainerClassName, + scrollAmount = 300, + dependencyList = defaultDependencies, +}) => { + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const scrollByDelta = (delta: number) => { + if (!scrollRef.current) { + return; + } + scrollRef.current.scrollBy({ left: delta, behavior: "smooth" }); + }; + + const updateScrollState = () => { + const element = scrollRef.current; + if (!element) { + setCanScrollLeft(false); + setCanScrollRight(false); + return; + } + setCanScrollLeft(element.scrollLeft > 0); + setCanScrollRight( + Math.ceil(element.scrollLeft + element.clientWidth) < element.scrollWidth, + ); + }; + + useEffect(() => { + updateScrollState(); + const element = scrollRef.current; + if (!element) { + return; + } + const handleScroll = () => updateScrollState(); + element.addEventListener("scroll", handleScroll); + window.addEventListener("resize", handleScroll); + return () => { + element.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", handleScroll); + }; + }, dependencyList); + + return ( +
+
+
+ {children} +
+ {canScrollLeft && ( +
+ )} + {canScrollRight && ( +
+ )} + {canScrollLeft && ( + + )} + {canScrollRight && ( + + )} +
+
+ ); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/SuggestionContent/SuggestionContent.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/SuggestionContent/SuggestionContent.tsx index 94efe063a6..b00714f4ca 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/SuggestionContent/SuggestionContent.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/SuggestionContent/SuggestionContent.tsx @@ -6,10 +6,15 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { blockMenuContainerStyle } from "../style"; import { useBlockMenuStore } from "../../../../stores/blockMenuStore"; import { DefaultStateType } from "../types"; +import { SearchHistoryChip } from "../SearchHistoryChip"; +import { HorizontalScroll } from "../HorizontalScroll"; export const SuggestionContent = () => { - const { setIntegration, setDefaultState } = useBlockMenuStore(); + const { setIntegration, setDefaultState, setSearchQuery, setSearchId } = + useBlockMenuStore(); const { data, isLoading, isError, error, refetch } = useSuggestionContent(); + const suggestions = data?.suggestions; + const hasRecentSearches = (suggestions?.recent_searches?.length ?? 0) > 0; if (isError) { return ( @@ -29,11 +34,45 @@ export const SuggestionContent = () => { ); } - const suggestions = data?.suggestions; - return (
+ {/* Recent searches */} + {hasRecentSearches && ( +
+

+ Recent searches +

+ + {!isLoading && suggestions + ? suggestions.recent_searches.map((entry, index) => ( + { + setSearchQuery(entry.search_query || ""); + setSearchId(entry.search_id || undefined); + }} + /> + )) + : Array(3) + .fill(0) + .map((_, index) => ( + + ))} + +
+ )} + {/* Integrations */}

diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index 9a35a7b465..f8c5563476 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -3662,7 +3662,18 @@ "required": false, "schema": { "anyOf": [ - { "type": "array", "items": { "type": "string" } }, + { + "type": "array", + "items": { + "enum": [ + "blocks", + "integrations", + "marketplace_agents", + "my_agents" + ], + "type": "string" + } + }, { "type": "null" } ], "title": "Filter" @@ -8612,6 +8623,45 @@ "required": ["name", "cron", "inputs"], "title": "ScheduleCreationRequest" }, + "SearchEntry": { + "properties": { + "search_query": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Search Query" + }, + "filter": { + "anyOf": [ + { + "items": { + "type": "string", + "enum": [ + "blocks", + "integrations", + "marketplace_agents", + "my_agents" + ] + }, + "type": "array" + }, + { "type": "null" } + ], + "title": "Filter" + }, + "by_creator": { + "anyOf": [ + { "items": { "type": "string" }, "type": "array" }, + { "type": "null" } + ], + "title": "By Creator" + }, + "search_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Search Id" + } + }, + "type": "object", + "title": "SearchEntry" + }, "SearchResponse": { "properties": { "items": { @@ -8625,6 +8675,7 @@ "type": "array", "title": "Items" }, + "search_id": { "type": "string", "title": "Search Id" }, "total_items": { "additionalProperties": { "type": "integer" }, "propertyNames": { @@ -8638,11 +8689,10 @@ "type": "object", "title": "Total Items" }, - "page": { "type": "integer", "title": "Page" }, - "more_pages": { "type": "boolean", "title": "More Pages" } + "pagination": { "$ref": "#/components/schemas/Pagination" } }, "type": "object", - "required": ["items", "total_items", "page", "more_pages"], + "required": ["items", "search_id", "total_items", "pagination"], "title": "SearchResponse" }, "SessionDetailResponse": { @@ -9199,7 +9249,7 @@ "title": "Otto Suggestions" }, "recent_searches": { - "items": { "type": "string" }, + "items": { "$ref": "#/components/schemas/SearchEntry" }, "type": "array", "title": "Recent Searches" },