Compare commits

...

127 Commits

Author SHA1 Message Date
Abhimanyu Yadav
3f917327b2 Merge branch 'dev' into redesigning-block-menu 2025-06-18 15:12:11 +05:30
Abhimanyu Yadav
53c92be68f Remove unused imports 2025-06-18 15:07:04 +05:30
Abhimanyu Yadav
7d0c4b9f7f Update pnpm-lock.yaml 2025-06-17 22:31:52 +05:30
abhi1992002
8902ecb3ae Merge branch 'dev' into redesigning-block-menu 2025-06-17 22:30:53 +05:30
Abhimanyu Yadav
845a6ab38b refactor(block-menu): remove commented-out code for recent searches in SuggestionContent to improve code clarity 2025-06-17 22:18:57 +05:30
Abhimanyu Yadav
79afd83919 refactor(block-menu): replace hardcoded scrollbar styles with centralized scrollbarStyles import for consistency across components 2025-06-17 22:15:50 +05:30
Abhimanyu Yadav
be8144b305 refactor(block-menu): optimize key assignment in IntegrationBlocks to use block ID for improved rendering 2025-06-17 21:59:27 +05:30
Abhimanyu Yadav
e63575877d refactor(block-menu): consolidate block content components into PaginatedBlocksContent for improved maintainability 2025-06-17 21:57:18 +05:30
Abhimanyu Yadav
9206d24017 refactor(block-menu): remove React.FC type annotation from block menu components for consistency 2025-06-17 21:54:45 +05:30
Abhimanyu Yadav
b2ab2602fe refactor(block-menu): optimize block key assignment in AllBlocksContent for improved rendering performance 2025-06-17 21:50:58 +05:30
Abhimanyu Yadav
aa4de454b2 refactor(block-menu): simplify useEffect dependencies in AllBlocksContent, IntegrationBlocks, and SuggestionContent components 2025-06-17 21:48:57 +05:30
Abhimanyu Yadav
9ea44b6267 fix format 2025-06-12 07:58:35 +05:30
Abhimanyu Yadav
3cd214d0d4 remove unused state and simplify hover behavior in FilterChip component 2025-06-12 07:55:11 +05:30
Abhimanyu Yadav
04d30efc5d refactor(block-menu): update button styles across components to improve disabled state visibility 2025-06-12 07:50:44 +05:30
Abhimanyu Yadav
9157388723 refactor(block-menu): enhance text highlighting functionality in IntegrationBlock by escaping special characters 2025-06-12 07:41:08 +05:30
Abhimanyu Yadav
455f273ccf refactor(block-menu): replace hardcoded filter defaults with getDefaultFilters utility for consistency 2025-06-12 07:34:38 +05:30
Abhimanyu Yadav
382598f2be refactor(block-menu): improve error message in useBlockMenuContext for clarity on provider usage 2025-06-12 07:28:57 +05:30
Abhimanyu Yadav
79b6a56b56 refactor(block-menu): adjust conditional rendering for image display in UGCAgentBlock component 2025-06-12 07:28:05 +05:30
Abhimanyu Yadav
68cec8b2e7 refactor(block-menu): enhance error handling in ErrorState component by introducing parseErrorMessage utility 2025-06-12 07:14:41 +05:30
Abhimanyu Yadav
b921edb062 refactor(block-menu): update ControlPanelButton styles for improved clarity and consistency 2025-06-12 07:09:13 +05:30
Abhimanyu Yadav
b7408415df refactor(block-menu): implement search debounce and update ControlPanelButton imports for consistency 2025-06-12 06:39:50 +05:30
Abhimanyu Yadav
59752054fa refactor(block-menu): export components in various files for improved modularity and consistency 2025-06-12 06:33:01 +05:30
Abhimanyu Yadav
478f31141d refactor(block-menu): export components in Block, BlockMenu, BlockMenuContent, and related files for improved modularity 2025-06-12 06:32:44 +05:30
Abhimanyu Yadav
5c264c253c refactor(block-menu): simplify callback functions in BlockMenu, BlockMenuContent, and FilterSheet components 2025-06-12 05:44:08 +05:30
Abhimanyu Yadav
d6d4703bbc refactor(block-menu): simplify conditional rendering for title and description in Block, Integration, IntegrationBlock, IntegrationChip, MarketplaceAgentBlock, and UGCAgentBlock components 2025-06-11 19:23:59 +05:30
Abhimanyu Yadav
0b602600cb refactor(styles): remove unused scroll-container styles from globals.css 2025-06-11 19:01:27 +05:30
Abhimanyu Yadav
19382072b1 chore: update integration images with compressed ones 2025-06-11 18:59:05 +05:30
Abhimanyu Yadav
3e2b388df0 Merge branch 'dev' into redesigning-block-menu 2025-06-09 21:29:30 +05:30
Abhimanyu Yadav
a50532a975 Merge branch 'dev' into redesigning-block-menu 2025-06-09 16:40:30 +05:30
Abhimanyu Yadav
27e53aa3dd Comment out monitor test suite 2025-06-09 10:45:25 +05:30
Abhimanyu Yadav
a24673d15f Comment out build.spec.ts test file 2025-06-09 10:34:58 +05:30
Abhimanyu Yadav
9d2d9606e8 Merge branch 'dev' into redesigning-block-menu 2025-06-09 10:17:38 +05:30
Abhimanyu Yadav
91407dfc33 Add expandable creator list in filter sheet menu 2025-06-06 18:34:00 +05:30
abhi1992002
851919d2d5 Merge branch 'dev' into redesigning-block-menu 2025-06-06 18:23:17 +05:30
Abhimanyu Yadav
d6acb02cb6 Merge branch 'dev' into redesigning-block-menu 2025-06-06 18:20:05 +05:30
Krzysztof Czerwinski
9c07206725 Merge branch 'redesigning-block-menu' into kpczerwinski/secrt-1320-backend-update 2025-06-06 14:43:17 +02:00
Krzysztof Czerwinski
4bd3447301 Cleanup and comments 2025-06-06 14:39:13 +02:00
Abhimanyu Yadav
8adc9f967d Remove commented TODO and clean up code formatting 2025-06-06 17:11:40 +05:30
Abhimanyu Yadav
349b70c4bc Remove unused imports and cleanup effects 2025-06-06 17:07:59 +05:30
Abhimanyu Yadav
9ecfa1e1f1 Add scrolling and fixed footer to filter sheet panel 2025-06-06 17:03:24 +05:30
Krzysztof Czerwinski
4e17f9c49e Include block costs in get_blocks 2025-06-06 13:05:44 +02:00
Krzysztof Czerwinski
31fdeeb706 Make agent_name optional 2025-06-06 13:03:32 +02:00
Abhimanyu Yadav
e42b24c029 Add hasLocalActiveFilters for applying filter state 2025-06-06 11:10:47 +05:30
Abhimanyu Yadav
2d52a57a21 Fix "Agent page" link propagation in menu block 2025-06-06 10:53:55 +05:30
Krzysztof Czerwinski
f45123f6b6 Fix Agent Executor block name 2025-06-05 17:00:40 +02:00
Abhimanyu Yadav
d524518f41 Update pnpm-lock.yaml 2025-06-05 18:32:07 +05:30
abhi1992002
81d1b28d92 Merge remote-tracking branch 'upstream/dev' into redesigning-block-menu 2025-06-05 18:30:19 +05:30
Abhimanyu Yadav
4e4e754ac1 Merge branch 'backend-temp' into redesigning-block-menu 2025-06-05 17:00:08 +05:30
Krzysztof Czerwinski
e409d7aa34 Fixes 2025-06-04 14:58:49 +02:00
Abhimanyu Yadav
8312a339c2 cleaning up frontend code 2025-06-04 11:22:46 +05:30
Abhimanyu Yadav
5b45d246ef fix blockType utils 2025-06-04 10:46:45 +05:30
Krzysztof Czerwinski
5c7c7ca874 Suggested blocks 2025-06-03 19:27:02 +02:00
Krzysztof Czerwinski
c93c5e35ba Merge branch 'redesigning-block-menu' into kpczerwinski/secrt-1320-backend-update 2025-06-03 11:45:34 +02:00
Abhimanyu Yadav
ce989b1bf7 remove providers from filter list and add support of ai blocks in search
list]
2025-06-03 10:46:25 +05:30
Abhimanyu Yadav
c1c919b88b Merge branch 'backend-temp' into redesigning-block-menu 2025-06-03 10:22:01 +05:30
Krzysztof Czerwinski
21a91fe9fd Merge branch 'redesigning-block-menu' into kpczerwinski/secrt-1320-backend-update 2025-06-02 15:07:16 +02:00
Krzysztof Czerwinski
b2f3d8c1f2 Search model names 2025-06-02 15:06:54 +02:00
Krzysztof Czerwinski
46ab2e3b20 Remove providers filter from search 2025-06-02 10:13:05 +02:00
Abhimanyu Yadav
5b40700299 fetching creator list from searchList
Moves the `getBlockType` function from the SearchList component to the
`utils.ts` file to make it more reusable. Also removes the unused
`creators` state and `setCreators` function from the
BlockMenuContext and instead calculates the creators list dynamically
within the FilterSheet component based on the available search data.
2025-06-02 13:07:35 +05:30
Abhimanyu Yadav
1a97020eeb fix marketplace agent block and libray agent block in searchList 2025-06-02 12:50:07 +05:30
Abhimanyu Yadav
39d03f2090 Add Marketplace Agents to builder
Adds functionality to add Marketplace agents to the user's library and then to builder.
Includes a loading indicator while the agent is being added.
Refactors agent-to-block conversion into a utility function.
2025-06-02 12:32:13 +05:30
Abhimanyu Yadav
8088d294f4 Add Agent Blocks to Flow
This commit adds the ability to add Agent blocks to the
flow.  Clicking on an agent in the My Agents menu will add
it to the flow.  The block includes the necessary
information such as input/output schemas.
2025-06-02 12:03:51 +05:30
Abhimanyu Yadav
31266949ed Clears all filters when the search input is cleared and redesign filter based on new design. 2025-06-02 11:34:01 +05:30
abhi1992002
f4eb00a6ad Fetch Block Counts in Block Menu
Adds API calls to fetch block counts for each category
in the block menu and displays them next to the category
name.  This replaces the hardcoded numbers previously
displayed.
2025-06-02 10:50:26 +05:30
Abhimanyu Yadav
f75cc0dd11 Merge branch 'dev' into redesigning-block-menu 2025-06-02 10:34:16 +05:30
Krzysztof Czerwinski
21b612625f Format frontend 2025-05-31 13:42:32 +02:00
Krzysztof Czerwinski
eec0d276d5 Add output_schema to LibraryAgent 2025-05-31 13:42:07 +02:00
Krzysztof Czerwinski
c6941e7f6e Merge branch 'redesigning-block-menu' into kpczerwinski/secrt-1320-backend-update 2025-05-31 12:49:36 +02:00
Abhimanyu Yadav
325684a10f remove recent searches from suggestionContent and done some cleanup as
well
2025-05-30 17:33:44 +05:30
Abhimanyu Yadav
cf057cbbda fixed pagination problem in default menus in block menu 2025-05-30 17:26:59 +05:30
Abhimanyu Yadav
f3a7be1fd3 add highlighted description while searching 2025-05-30 12:06:36 +05:30
Abhimanyu Yadav
97bcb0f95e fix searchlist pagination 2025-05-30 11:11:40 +05:30
Abhimanyu Yadav
dd71d65706 adding beautify String in integration chips 2025-05-30 10:57:08 +05:30
Abhimanyu Yadav
2b2d26bcde remove items expanding when selecting menus 2025-05-30 10:54:56 +05:30
Abhimanyu Yadav
67f6f43e1b fix error state layout in input/output/action blocks list 2025-05-30 10:43:03 +05:30
Abhimanyu Yadav
a3409c9578 fix MarketplaceAgentBlock layout 2025-05-30 10:29:53 +05:30
Abhimanyu Yadav
7f82457ea4 add external agent link to marketplace agent block 2025-05-30 10:25:40 +05:30
Abhimanyu Yadav
a5c0fabc00 fix design of clear button in searchMenuBar 2025-05-30 10:12:09 +05:30
Krzysztof Czerwinski
09dba93a4a Add counts endpoint 2025-05-29 16:17:33 +02:00
Krzysztof Czerwinski
ea2cd3e7bf Merge branch 'redesigning-block-menu' into kpczerwinski/secrt-1320-backend-update 2025-05-29 13:16:18 +02:00
Abhimanyu Yadav
d3d0ccf732 fix menu item hover state and add a clear button at the end of searchbar 2025-05-29 11:09:55 +05:30
Abhimanyu Yadav
d8d5d6ec0c make hover state correct on all reusable compoents in block menu 2025-05-29 11:01:09 +05:30
Abhimanyu Yadav
f45b09c0b5 fix hover state and heading text in suggestion content page 2025-05-29 10:57:08 +05:30
Abhimanyu Yadav
1e89b6d3a4 add beautifyString in block, integration and integration block 2025-05-29 10:31:10 +05:30
Abhimanyu Yadav
950a85e179 fix image sizes warning with fill 2025-05-28 17:40:35 +05:30
Abhimanyu Yadav
c5e3148145 add better error handling in all components 2025-05-28 17:27:07 +05:30
Abhimanyu Yadav
a135ba3f0b refactor addBlock implementation in flow.tsx 2025-05-28 15:58:31 +05:30
Abhimanyu Yadav
fe95e27226 only show scroller when hovering 2025-05-28 15:48:28 +05:30
Abhimanyu Yadav
711ca10cc9 add relative time in my_agent block using react-timeago library 2025-05-28 15:29:34 +05:30
Abhimanyu Yadav
1346d8230c add 500ms debouncer on searchbar 2025-05-28 15:19:56 +05:30
Abhimanyu Yadav
07c84a4757 add categories filter in search 2025-05-28 13:57:39 +05:30
Abhimanyu Yadav
596824c1e7 add pagination on search list 2025-05-28 13:28:32 +05:30
Abhimanyu Yadav
79afa6db99 add search functioanlity in block menu 2025-05-28 12:15:38 +05:30
Abhimanyu Yadav
e034c16f31 add pagination in all components in default state 2025-05-26 21:13:51 +05:30
Abhimanyu Yadav
9012eff1ac add basic data fetching in all default state components 2025-05-26 10:27:15 +05:30
Abhimanyu Yadav
0361ea4aa4 connection integration list and blocks 2025-05-26 00:25:30 +05:30
Abhimanyu Yadav
6f1c522ea3 add some images and connect suggestion content frontend with backend 2025-05-25 23:09:22 +05:30
Krzysztof Czerwinski
2d654bf64b Update frontend types and api client 2025-05-25 15:12:01 +02:00
Krzysztof Czerwinski
bb69e32fee Update backend 2025-05-25 15:11:29 +02:00
Krzysztof Czerwinski
1be830835b Update signatures, disable providers 2025-05-23 17:22:35 +02:00
Krzysztof Czerwinski
a2a4d546f7 Merge branch 'redesigning-block-menu' into kpczerwinski/secrt-1320-backend-update 2025-05-23 16:51:53 +02:00
Krzysztof Czerwinski
3053a7bd06 Add types and function on the frontend 2025-05-23 16:50:52 +02:00
Krzysztof Czerwinski
bbf4108136 Add builder router and get_blocks endpoint 2025-05-23 16:50:05 +02:00
Krzysztof Czerwinski
95387bcf78 Add model and functions 2025-05-23 16:48:34 +02:00
Abhimanyu Yadav
e1fc56e6f3 fix small optimisation and DX issue 2025-05-21 18:10:29 +05:30
Abhimanyu Yadav
2a06956802 fix max width in sidebar 2025-05-20 17:01:03 +05:30
Abhimanyu Yadav
32231ff80f Implement search text highlighting in Block components, add transitions
to FilterChip, and create NoSearchResult component for empty searches. Move
SearchItem types to provider context for better access.
2025-05-20 15:31:02 +05:30
Abhimanyu Yadav
d0b23c085f add context api for block menu 2025-05-20 11:58:45 +05:30
Abhimanyu Yadav
e718d3d3d8 fix filter sheets 2025-05-20 11:25:46 +05:30
Abhimanyu Yadav
1971a62684 fix checkbox tick design 2025-05-20 10:38:51 +05:30
Abhimanyu Yadav
e125b5923c fix width of left sidebar 2025-05-20 10:30:18 +05:30
Abhimanyu Yadav
c6942e4e6f prevent layout shift when clicking result elements with border 2025-05-20 10:19:02 +05:30
Abhimanyu Yadav
c9e421a219 Merge branch 'dev' into redesigning-block-menu 2025-05-19 22:27:23 +05:30
Abhimanyu Yadav
7868373897 fix comments 2025-05-19 17:06:17 +05:30
Abhimanyu Yadav
f1c8399e0e fix recent searches onClick 2025-05-19 16:55:59 +05:30
Abhimanyu Yadav
97ba69ef1c fix lint 2025-05-19 16:35:55 +05:30
Abhimanyu Yadav
773e1488bf add filter sheet 2025-05-19 16:34:26 +05:30
Abhimanyu Yadav
4273be59ba fix format 2025-05-19 15:37:13 +05:30
Abhimanyu Yadav
06e524788a fix format 2025-05-18 21:10:26 +05:30
Abhimanyu Yadav
bc08012771 add search list in block menu 2025-05-18 21:10:19 +05:30
Abhimanyu Yadav
4af0aedebd fix format 2025-05-18 17:16:45 +05:30
Abhimanyu Yadav
d22464a75e Add skeleton components and loading states 2025-05-18 17:16:08 +05:30
Abhimanyu Yadav
82e3a485f0 complete frontend design for default state 2025-05-18 10:19:25 +05:30
Abhimanyu Yadav
8165ad5879 fix scrollbar in default content 2025-05-18 08:52:20 +05:30
Abhimanyu Yadav
451284de76 Add tailwind-scrollbar-hide and implement block menu UI
The commit adds a new block menu UI component with sidebar navigation,
integration chips, and scrollable content areas. It includes tailwind-
scrollbar-hide for better UI experience and custom CSS for scroll
containers. The implementation features different content sections
for blocks categorized by type (input, action, output) and supports
search functionality.
2025-05-17 21:18:08 +05:30
Abhimanyu Yadav
1d8c7c5e1a Merge branch 'dev' into redesigning-block-menu 2025-05-17 00:13:52 +05:30
Abhimanyu Yadav
34be6a3379 creating small ui reusable component 2025-05-17 00:01:40 +05:30
104 changed files with 4942 additions and 1024 deletions

View File

@@ -23,6 +23,9 @@ class AgentExecutorBlock(Block):
user_id: str = SchemaField(description="User ID")
graph_id: str = SchemaField(description="Graph ID")
graph_version: int = SchemaField(description="Graph Version")
agent_name: Optional[str] = SchemaField(
default=None, description="Name to display in the Builder UI"
)
inputs: BlockInput = SchemaField(description="Input data for the graph")
input_schema: dict = SchemaField(description="Input schema for the graph")

View File

@@ -74,6 +74,15 @@ class Pagination(pydantic.BaseModel):
description="Number of items per page.", examples=[25]
)
@staticmethod
def empty() -> "Pagination":
return Pagination(
total_items=0,
total_pages=0,
current_page=0,
page_size=0,
)
class RequestTopUp(pydantic.BaseModel):
credit_amount: int

View File

@@ -23,6 +23,8 @@ import backend.server.routers.postmark.postmark
import backend.server.routers.v1
import backend.server.v2.admin.credit_admin_routes
import backend.server.v2.admin.store_admin_routes
import backend.server.v2.builder
import backend.server.v2.builder.routes
import backend.server.v2.library.db
import backend.server.v2.library.model
import backend.server.v2.library.routes
@@ -144,6 +146,9 @@ app.include_router(backend.server.routers.v1.v1_router, tags=["v1"], prefix="/ap
app.include_router(
backend.server.v2.store.routes.router, tags=["v2"], prefix="/api/store"
)
app.include_router(
backend.server.v2.builder.routes.router, tags=["v2"], prefix="/api/builder"
)
app.include_router(
backend.server.v2.admin.store_admin_routes.router,
tags=["v2", "admin"],

View File

@@ -0,0 +1,370 @@
import functools
import logging
import prisma
import backend.data.block
import backend.server.model as server_model
from backend.blocks import load_all_blocks
from backend.blocks.llm import LlmModel
from backend.data.block import Block, BlockCategory, BlockSchema
from backend.data.credit import get_block_costs
from backend.integrations.providers import ProviderName
from backend.server.v2.builder.model import (
BlockCategoryResponse,
BlockData,
BlockResponse,
BlockType,
CountResponse,
Provider,
ProviderResponse,
SearchBlocksResponse,
)
logger = logging.getLogger(__name__)
llm_models = [name.name.lower().replace("_", " ") for name in LlmModel]
_static_counts_cache: dict | None = None
_suggested_blocks: list[BlockData] | None = None
def get_block_categories(category_blocks: int = 3) -> list[BlockCategoryResponse]:
categories: dict[BlockCategory, BlockCategoryResponse] = {}
for block_type in load_all_blocks().values():
block: Block[BlockSchema, BlockSchema] = block_type()
# Skip disabled blocks
if block.disabled:
continue
# Skip blocks that don't have categories (all should have at least one)
if not block.categories:
continue
# Add block to the categories
for category in block.categories:
if category not in categories:
categories[category] = BlockCategoryResponse(
name=category.name.lower(),
total_blocks=0,
blocks=[],
)
categories[category].total_blocks += 1
# Append if the category has less than the specified number of blocks
if len(categories[category].blocks) < category_blocks:
categories[category].blocks.append(block.to_dict())
# Sort categories by name
return sorted(categories.values(), key=lambda x: x.name)
def get_blocks(
*,
category: str | None = None,
type: BlockType | None = None,
provider: ProviderName | None = None,
page: int = 1,
page_size: int = 50,
) -> BlockResponse:
"""
Get blocks based on either category, type or provider.
Providing nothing fetches all block types.
"""
# Only one of category, type, or provider can be specified
if (category and type) or (category and provider) or (type and provider):
raise ValueError("Only one of category, type, or provider can be specified")
blocks: list[Block[BlockSchema, BlockSchema]] = []
skip = (page - 1) * page_size
take = page_size
total = 0
for block_type in load_all_blocks().values():
block: Block[BlockSchema, BlockSchema] = block_type()
# Skip disabled blocks
if block.disabled:
continue
# Skip blocks that don't match the category
if category and category not in {c.name.lower() for c in block.categories}:
continue
# Skip blocks that don't match the type
if (
(type == "input" and block.block_type.value != "Input")
or (type == "output" and block.block_type.value != "Output")
or (type == "action" and block.block_type.value in ("Input", "Output"))
):
continue
# Skip blocks that don't match the provider
if provider:
credentials_info = block.input_schema.get_credentials_fields_info().values()
if not any(provider in info.provider for info in credentials_info):
continue
total += 1
if skip > 0:
skip -= 1
continue
if take > 0:
take -= 1
blocks.append(block)
costs = get_block_costs()
return BlockResponse(
blocks=[{**b.to_dict(), "costs": costs.get(b.id, [])} for b in blocks],
pagination=server_model.Pagination(
total_items=total,
total_pages=total // page_size + (1 if total % page_size > 0 else 0),
current_page=page,
page_size=page_size,
),
)
def search_blocks(
include_blocks: bool = True,
include_integrations: bool = True,
query: str = "",
page: int = 1,
page_size: int = 50,
) -> SearchBlocksResponse:
"""
Get blocks based on the filter and query.
`providers` only applies for `integrations` filter.
"""
blocks: list[Block[BlockSchema, BlockSchema]] = []
query = query.lower()
total = 0
skip = (page - 1) * page_size
take = page_size
block_count = 0
integration_count = 0
for block_type in load_all_blocks().values():
block: Block[BlockSchema, BlockSchema] = 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
credentials = list(block.input_schema.get_credentials_fields().values())
if include_integrations and len(credentials) > 0:
keep = True
integration_count += 1
if include_blocks and len(credentials) == 0:
keep = True
block_count += 1
if not keep:
continue
total += 1
if skip > 0:
skip -= 1
continue
if take > 0:
take -= 1
blocks.append(block)
costs = get_block_costs()
return SearchBlocksResponse(
blocks=BlockResponse(
blocks=[{**b.to_dict(), "costs": costs.get(b.id, [])} for b in blocks],
pagination=server_model.Pagination(
total_items=total,
total_pages=total // page_size + (1 if total % page_size > 0 else 0),
current_page=page,
page_size=page_size,
),
),
total_block_count=block_count,
total_integration_count=integration_count,
)
def get_providers(
query: str = "",
page: int = 1,
page_size: int = 50,
) -> ProviderResponse:
providers = []
query = query.lower()
skip = (page - 1) * page_size
take = page_size
all_providers = _get_all_providers()
for provider in all_providers.values():
if (
query not in provider.name.value.lower()
and query not in provider.description.lower()
):
continue
if skip > 0:
skip -= 1
continue
if take > 0:
take -= 1
providers.append(provider)
total = len(all_providers)
return ProviderResponse(
providers=providers,
pagination=server_model.Pagination(
total_items=total,
total_pages=total // page_size + (1 if total % page_size > 0 else 0),
current_page=page,
page_size=page_size,
),
)
async def get_counts(user_id: str) -> CountResponse:
my_agents = await prisma.models.LibraryAgent.prisma().count(
where={
"userId": user_id,
"isDeleted": False,
"isArchived": False,
}
)
counts = await _get_static_counts()
return CountResponse(
my_agents=my_agents,
**counts,
)
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
output_blocks = 0
integrations = 0
for block_type in load_all_blocks().values():
block: Block[BlockSchema, BlockSchema] = block_type()
if block.disabled:
continue
all_blocks += 1
if block.block_type.value == "Input":
input_blocks += 1
elif block.block_type.value == "Output":
output_blocks += 1
else:
action_blocks += 1
credentials = list(block.input_schema.get_credentials_fields().values())
if len(credentials) > 0:
integrations += 1
marketplace_agents = await prisma.models.StoreAgent.prisma().count()
_static_counts_cache = {
"all_blocks": all_blocks,
"input_blocks": input_blocks,
"action_blocks": action_blocks,
"output_blocks": output_blocks,
"integrations": integrations,
"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():
if field.annotation == LlmModel:
# Check if query matches any value in llm_models
if any(query in name for name in llm_models):
return True
return False
@functools.cache
def _get_all_providers() -> dict[ProviderName, Provider]:
providers: dict[ProviderName, Provider] = {}
for block_type in load_all_blocks().values():
block: Block[BlockSchema, BlockSchema] = block_type()
if block.disabled:
continue
credentials_info = block.input_schema.get_credentials_fields_info().values()
for info in credentials_info:
for provider in info.provider: # provider is a ProviderName enum member
if provider in providers:
providers[provider].integration_count += 1
else:
providers[provider] = Provider(
name=provider, description="", integration_count=1
)
return providers
async def get_suggested_blocks(count: int = 5) -> list[BlockData]:
global _suggested_blocks
if _suggested_blocks is not None and len(_suggested_blocks) >= count:
return _suggested_blocks[:count]
_suggested_blocks = []
# Sum the number of executions for each block type
# Prisma cannot group by nested relations, so we do a raw query
results = await prisma.get_client().query_raw(
"""
SELECT
agent_node."agentBlockId" AS block_id,
COUNT(execution.id) AS execution_count
FROM "AgentNodeExecution" execution
JOIN "AgentNode" agent_node ON execution."agentNodeId" = agent_node.id
GROUP BY agent_node."agentBlockId"
ORDER BY execution_count DESC;
"""
)
# Get the top blocks based on execution count
# But ignore Input and Output blocks
blocks: list[tuple[BlockData, int]] = []
for block_type in load_all_blocks().values():
block: Block[BlockSchema, BlockSchema] = block_type()
if block.disabled or block.block_type in (
backend.data.block.BlockType.INPUT,
backend.data.block.BlockType.OUTPUT,
backend.data.block.BlockType.AGENT,
):
continue
# Find the execution count for this block
execution_count = next(
(row["execution_count"] for row in results if row["block_id"] == block.id),
0,
)
blocks.append((block.to_dict(), execution_count))
# Sort blocks by execution count
blocks.sort(key=lambda x: x[1], reverse=True)
_suggested_blocks = [block[0] for block in blocks]
# Return the top blocks
return _suggested_blocks[:count]

View File

@@ -0,0 +1,87 @@
from typing import Any, Literal
from pydantic import BaseModel
import backend.server.model as server_model
import backend.server.v2.library.model as library_model
import backend.server.v2.store.model as store_model
from backend.integrations.providers import ProviderName
FilterType = Literal[
"blocks",
"integrations",
"marketplace_agents",
"my_agents",
]
BlockType = Literal["all", "input", "action", "output"]
BlockData = dict[str, Any]
# Suggestions
class SuggestionsResponse(BaseModel):
otto_suggestions: list[str]
recent_searches: list[str]
providers: list[ProviderName]
top_blocks: list[BlockData]
# All blocks
class BlockCategoryResponse(BaseModel):
name: str
total_blocks: int
blocks: list[BlockData]
model_config = {"use_enum_values": False} # <== use enum names like "AI"
# Input/Action/Output and see all for block categories
class BlockResponse(BaseModel):
blocks: list[BlockData]
pagination: server_model.Pagination
# Providers
class Provider(BaseModel):
name: ProviderName
description: str
integration_count: int
class ProviderResponse(BaseModel):
providers: list[Provider]
pagination: server_model.Pagination
# Search
class SearchRequest(BaseModel):
search_query: str | None = None
filter: list[FilterType] | None = None
by_creator: list[str] | None = None
search_id: str | None = None
page: int | None = None
page_size: int | None = None
class SearchBlocksResponse(BaseModel):
blocks: BlockResponse
total_block_count: int
total_integration_count: int
class SearchResponse(BaseModel):
items: list[BlockData | library_model.LibraryAgent | store_model.StoreAgent]
total_items: dict[FilterType, int]
page: int
more_pages: bool
class CountResponse(BaseModel):
all_blocks: int
input_blocks: int
action_blocks: int
output_blocks: int
integrations: int
marketplace_agents: int
my_agents: int

View File

@@ -0,0 +1,227 @@
import logging
from typing import Annotated, Sequence
import fastapi
from autogpt_libs.auth.depends import auth_middleware, get_user_id
import backend.server.model as server_model
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
logger = logging.getLogger(__name__)
router = fastapi.APIRouter()
# Taken from backend/server/v2/store/db.py
def sanitize_query(query: str | None) -> str | None:
if query is None:
return query
query = query.strip()[:100]
return (
query.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
.replace("[", "\\[")
.replace("]", "\\]")
.replace("'", "\\'")
.replace('"', '\\"')
.replace(";", "\\;")
.replace("--", "\\--")
.replace("/*", "\\/*")
.replace("*/", "\\*/")
)
@router.get(
"/suggestions",
dependencies=[fastapi.Depends(auth_middleware)],
)
async def get_suggestions(
user_id: Annotated[str, fastapi.Depends(get_user_id)],
) -> builder_model.SuggestionsResponse:
"""
Get all suggestions for the Blocks Menu.
"""
return builder_model.SuggestionsResponse(
otto_suggestions=[
"What blocks do I need to get started?",
"Help me create a list",
"Help me feed my data to Google Maps",
],
recent_searches=[
"image generation",
"deepfake",
"competitor analysis",
],
providers=[
ProviderName.TWITTER,
ProviderName.GITHUB,
ProviderName.NOTION,
ProviderName.GOOGLE,
ProviderName.DISCORD,
ProviderName.GOOGLE_MAPS,
],
top_blocks=await builder_db.get_suggested_blocks(),
)
@router.get(
"/categories",
dependencies=[fastapi.Depends(auth_middleware)],
)
async def get_block_categories(
category_blocks: Annotated[int, fastapi.Query()] = 3,
) -> Sequence[builder_model.BlockCategoryResponse]:
"""
Get all block categories with a specified number of blocks per category.
"""
return builder_db.get_block_categories(category_blocks)
@router.get(
"/blocks",
dependencies=[fastapi.Depends(auth_middleware)],
)
async def get_blocks(
category: Annotated[str | None, fastapi.Query()] = None,
type: Annotated[builder_model.BlockType | None, fastapi.Query()] = None,
provider: Annotated[ProviderName | None, fastapi.Query()] = None,
page: Annotated[int, fastapi.Query()] = 1,
page_size: Annotated[int, fastapi.Query()] = 50,
) -> builder_model.BlockResponse:
"""
Get blocks based on either category, type, or provider.
"""
return builder_db.get_blocks(
category=category,
type=type,
provider=provider,
page=page,
page_size=page_size,
)
@router.get(
"/providers",
dependencies=[fastapi.Depends(auth_middleware)],
)
async def get_providers(
page: Annotated[int, fastapi.Query()] = 1,
page_size: Annotated[int, fastapi.Query()] = 50,
) -> builder_model.ProviderResponse:
"""
Get all integration providers with their block counts.
"""
return builder_db.get_providers(
page=page,
page_size=page_size,
)
@router.post(
"/search",
tags=["store", "private"],
dependencies=[fastapi.Depends(auth_middleware)],
)
async def search(
options: builder_model.SearchRequest,
user_id: Annotated[str, fastapi.Depends(get_user_id)],
) -> builder_model.SearchResponse:
"""
Search for blocks (including integrations), marketplace agents, and user library agents.
"""
# If no filters are provided, then we will return all types
if not options.filter:
options.filter = [
"blocks",
"integrations",
"marketplace_agents",
"my_agents",
]
options.search_query = sanitize_query(options.search_query)
options.page = options.page or 1
options.page_size = options.page_size or 50
# Blocks&Integrations
blocks = builder_model.SearchBlocksResponse(
blocks=builder_model.BlockResponse(
blocks=[],
pagination=server_model.Pagination.empty(),
),
total_block_count=0,
total_integration_count=0,
)
if "blocks" in options.filter or "integrations" in options.filter:
blocks = builder_db.search_blocks(
include_blocks="blocks" in options.filter,
include_integrations="integrations" in options.filter,
query=options.search_query or "",
page=options.page,
page_size=options.page_size,
)
# Library Agents
my_agents = library_model.LibraryAgentResponse(
agents=[],
pagination=server_model.Pagination.empty(),
)
if "my_agents" in options.filter:
my_agents = await library_db.list_library_agents(
user_id=user_id,
search_term=options.search_query,
page=options.page,
page_size=options.page_size,
)
# Marketplace Agents
marketplace_agents = store_model.StoreAgentsResponse(
agents=[],
pagination=server_model.Pagination.empty(),
)
if "marketplace_agents" in options.filter:
marketplace_agents = await store_db.get_store_agents(
creators=options.by_creator,
search_query=options.search_query,
page=options.page,
page_size=options.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
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=options.page,
more_pages=more_pages,
)
@router.get(
"/counts",
dependencies=[fastapi.Depends(auth_middleware)],
)
async def get_counts(
user_id: Annotated[str, fastapi.Depends(get_user_id)],
) -> builder_model.CountResponse:
"""
Get item counts for the menu categories in the Blocks Menu.
"""
return await builder_db.get_counts(user_id)

View File

@@ -448,7 +448,7 @@ async def add_store_agent_to_library(
"agentGraphVersion": graph.version,
}
},
include={"AgentGraph": True},
include=library_agent_include(user_id),
)
)
if existing_library_agent:

View File

@@ -42,6 +42,7 @@ class LibraryAgent(pydantic.BaseModel):
# Made input_schema and output_schema match GraphMeta's type
input_schema: dict[str, Any] # Should be BlockIOObjectSubSchema in frontend
output_schema: dict[str, Any]
# Indicates whether there's a new output (based on recent runs)
new_output: bool
@@ -106,6 +107,7 @@ class LibraryAgent(pydantic.BaseModel):
name=graph.name,
description=graph.description,
input_schema=graph.input_schema,
output_schema=graph.output_schema,
new_output=new_output,
can_access_graph=can_access_graph,
is_latest_version=is_latest_version,

View File

@@ -50,6 +50,7 @@ async def test_get_library_agents_success(
creator_name="Test Creator",
creator_image_url="",
input_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
status=library_model.LibraryAgentStatus.COMPLETED,
new_output=False,
can_access_graph=True,
@@ -66,6 +67,7 @@ async def test_get_library_agents_success(
creator_name="Test Creator",
creator_image_url="",
input_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
status=library_model.LibraryAgentStatus.COMPLETED,
new_output=False,
can_access_graph=False,

View File

@@ -37,7 +37,7 @@ def sanitize_query(query: str | None) -> str | None:
async def get_store_agents(
featured: bool = False,
creator: str | None = None,
creators: list[str] | None = None,
sorted_by: str | None = None,
search_query: str | None = None,
category: str | None = None,
@@ -48,15 +48,15 @@ async def get_store_agents(
Get PUBLIC store agents from the StoreAgent view
"""
logger.debug(
f"Getting store agents. featured={featured}, creator={creator}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}"
f"Getting store agents. featured={featured}, creator={creators}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}"
)
sanitized_query = sanitize_query(search_query)
where_clause = {}
if featured:
where_clause["featured"] = featured
if creator:
where_clause["creator_username"] = creator
if creators:
where_clause["creator_username"] = {"in": creators}
if category:
where_clause["categories"] = {"has": category}

View File

@@ -158,7 +158,7 @@ async def get_agents(
try:
agents = await backend.server.v2.store.db.get_store_agents(
featured=featured,
creator=creator,
creators=[creator] if creator else None,
sorted_by=sorted_by,
search_query=search_query,
category=category,

View File

@@ -82,9 +82,12 @@
"react-markdown": "9.0.3",
"react-modal": "3.16.3",
"react-shepherd": "6.1.8",
"react-timeago": "^8.2.0",
"recharts": "2.15.3",
"shepherd.js": "14.5.0",
"tailwind-merge": "2.6.0",
"tailwind-scrollbar": "^4.0.2",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "1.0.7",
"uuid": "11.1.0",
"zod": "3.25.56"

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -28,6 +28,7 @@ import "@xyflow/react/dist/style.css";
import { CustomNode } from "./CustomNode";
import "./flow.css";
import {
Block,
BlockUIType,
formatEdgeID,
GraphExecutionID,
@@ -39,7 +40,6 @@ import { CustomEdge } from "./CustomEdge";
import ConnectionLine from "./ConnectionLine";
import { Control, ControlPanel } from "@/components/edit/control/ControlPanel";
import { SaveControl } from "@/components/edit/control/SaveControl";
import { BlocksControl } from "@/components/edit/control/BlocksControl";
import { IconUndo2, IconRedo2 } from "@/components/ui/icons";
import { startTutorial } from "./tutorial";
import useAgentGraph from "@/hooks/useAgentGraph";
@@ -53,6 +53,7 @@ import OttoChatWidget from "@/components/OttoChatWidget";
import { useToast } from "@/components/ui/use-toast";
import { useCopyPaste } from "../hooks/useCopyPaste";
import { CronScheduler } from "./cronScheduler";
import { BlockMenu } from "./builder/block-menu/BlockMenu";
// This is for the history, this is the minimum distance a block must move before it is logged
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
@@ -101,7 +102,6 @@ const FlowEditor: React.FC<{
setAgentDescription,
savedAgent,
availableNodes,
availableFlows,
getOutputType,
requestSave,
requestSaveAndRun,
@@ -136,6 +136,10 @@ const FlowEditor: React.FC<{
// State to control if save popover should be pinned open
const [pinSavePopover, setPinSavePopover] = useState(false);
const [blockMenuSelected, setBlockMenuSelected] = useState<
"save" | "block" | ""
>("");
const runnerUIRef = useRef<RunnerUIWrapperRef>(null);
const [openCron, setOpenCron] = useState(false);
@@ -466,13 +470,7 @@ const FlowEditor: React.FC<{
}, [nodes, setViewport, x, y]);
const addNode = useCallback(
(blockId: string, nodeType: string, hardcodedValues: any = {}) => {
const nodeSchema = availableNodes.find((node) => node.id === blockId);
if (!nodeSchema) {
console.error(`Schema not found for block ID: ${blockId}`);
return;
}
(block: Block) => {
/*
Calculate a position to the right of the newly added block, allowing for some margin.
If adding to the right side causes the new block to collide with an existing block, attempt to place it at the bottom or left.
@@ -489,7 +487,7 @@ const FlowEditor: React.FC<{
? // we will get all the dimension of nodes, then store
findNewlyAddedBlockCoordinates(
nodeDimensions,
nodeSchema.uiType == BlockUIType.NOTE ? 300 : 500,
block.uiType == BlockUIType.NOTE ? 300 : 500,
60,
1.0,
)
@@ -504,19 +502,19 @@ const FlowEditor: React.FC<{
type: "custom",
position: viewportCoordinates, // Set the position to the calculated viewport center
data: {
blockType: nodeType,
blockCosts: nodeSchema.costs,
title: `${nodeType} ${nodeId}`,
description: nodeSchema.description,
categories: nodeSchema.categories,
inputSchema: nodeSchema.inputSchema,
outputSchema: nodeSchema.outputSchema,
hardcodedValues: hardcodedValues,
blockType: block.name,
blockCosts: block.costs,
title: `${block.name} ${nodeId}`,
description: block.description,
categories: block.categories,
inputSchema: block.inputSchema,
outputSchema: block.outputSchema,
hardcodedValues: block.hardcodedValues || {},
connections: [],
isOutputOpen: false,
block_id: blockId,
isOutputStatic: nodeSchema.staticOutput,
uiType: nodeSchema.uiType,
block_id: block.id,
isOutputStatic: block.staticOutput,
uiType: block.uiType,
},
};
@@ -545,7 +543,6 @@ const FlowEditor: React.FC<{
[
nodeId,
setViewport,
availableNodes,
addNodes,
nodeDimensions,
deleteElements,
@@ -627,12 +624,12 @@ const FlowEditor: React.FC<{
const editorControls: Control[] = [
{
label: "Undo",
icon: <IconUndo2 />,
icon: <IconUndo2 className="h-5 w-5" strokeWidth={2} />,
onClick: handleUndo,
},
{
label: "Redo",
icon: <IconRedo2 />,
icon: <IconRedo2 className="h-5 w-5" strokeWidth={2} />,
onClick: handleRedo,
},
];
@@ -680,15 +677,13 @@ const FlowEditor: React.FC<{
<Controls />
<Background className="dark:bg-slate-800" />
<ControlPanel
className="absolute z-20"
controls={editorControls}
topChildren={
<BlocksControl
pinBlocksPopover={pinBlocksPopover} // Pass the state to BlocksControl
blocks={availableNodes}
addBlock={addNode}
flows={availableFlows}
nodes={nodes}
<BlockMenu
pinBlocksPopover={pinBlocksPopover}
addNode={addNode}
blockMenuSelected={blockMenuSelected}
setBlockMenuSelected={setBlockMenuSelected}
/>
}
botChildren={
@@ -701,6 +696,8 @@ const FlowEditor: React.FC<{
agentName={agentName}
onNameChange={setAgentName}
pinSavePopover={pinSavePopover}
blockMenuSelected={blockMenuSelected}
setBlockMenuSelected={setBlockMenuSelected}
/>
}
></ControlPanel>

View File

@@ -0,0 +1,77 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { beautifyString, cn } from "@/lib/utils";
import { Plus } from "lucide-react";
import React, { ButtonHTMLAttributes } from "react";
import { highlightText } from "./IntegrationBlock";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
highlightedText?: string;
}
interface BlockComponent extends React.FC<Props> {
Skeleton: React.FC<{ className?: string }>;
}
export const Block: BlockComponent = ({
title,
description,
highlightedText,
className,
...rest
}) => {
return (
<Button
className={cn(
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
className,
)}
{...rest}
>
<div className="flex flex-1 flex-col items-start gap-0.5">
{title && (
<span
className={cn(
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 group-disabled:text-zinc-400",
)}
>
{highlightText(beautifyString(title), highlightedText)}
</span>
)}
{description && (
<span
className={cn(
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
{highlightText(description, highlightedText)}
</span>
)}
</div>
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
)}
>
<Plus className="h-5 w-5 text-zinc-50" strokeWidth={2} />
</div>
</Button>
);
};
const BlockSkeleton = () => {
return (
<Skeleton className="flex h-16 w-full min-w-[7.5rem] animate-pulse items-center justify-start space-x-3 rounded-[0.75rem] bg-zinc-100 px-[0.875rem] py-[0.625rem]">
<div className="flex flex-1 flex-col items-start gap-0.5">
<Skeleton className="h-[1.375rem] w-24 rounded bg-zinc-200" />
<Skeleton className="h-5 w-32 rounded bg-zinc-200" />
</div>
<Skeleton className="h-7 w-7 rounded-[0.5rem] bg-zinc-200" />
</Skeleton>
);
};
Block.Skeleton = BlockSkeleton;

View File

@@ -0,0 +1,62 @@
import React, { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ControlPanelButton } from "@/components/builder/block-menu/ControlPanelButton";
import { ToyBrick } from "lucide-react";
import { BlockMenuContent } from "./BlockMenuContent";
import { BlockMenuStateProvider } from "./block-menu-provider";
import { Block } from "@/lib/autogpt-server-api";
interface BlockMenuProps {
addNode: (block: Block) => void;
pinBlocksPopover: boolean;
blockMenuSelected: "save" | "block" | "";
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block">
>;
}
export const BlockMenu: React.FC<BlockMenuProps> = ({
addNode,
pinBlocksPopover,
blockMenuSelected,
setBlockMenuSelected,
}) => {
const [open, setOpen] = useState(false);
const onOpen = (newOpen: boolean) => {
if (!pinBlocksPopover) {
setOpen(newOpen);
setBlockMenuSelected(newOpen ? "block" : "");
}
};
return (
<Popover open={pinBlocksPopover ? true : open} onOpenChange={onOpen}>
<PopoverTrigger className="hover:cursor-pointer">
<ControlPanelButton
data-id="blocks-control-popover-trigger"
data-testid="blocks-control-blocks-button"
selected={blockMenuSelected === "block"}
className="rounded-none"
>
<ToyBrick className="h-5 w-6" strokeWidth={2} />
</ControlPanelButton>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={16}
className="absolute h-[75vh] w-[46.625rem] overflow-hidden rounded-[1rem] border-none p-0 shadow-[0_2px_6px_0_rgba(0,0,0,0.05)]"
data-id="blocks-control-popover-content"
>
<BlockMenuStateProvider addNode={addNode}>
<BlockMenuContent />
</BlockMenuStateProvider>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,18 @@
"use client";
import React from "react";
import { BlockMenuSearchBar } from "./BlockMenuSearchBar";
import { BlockMenuSearch } from "./search-and-filter//BlockMenuSearch";
import { BlockMenuDefault } from "./default/BlockMenuDefault";
import { Separator } from "@/components/ui/separator";
import { useBlockMenuContext } from "./block-menu-provider";
export const BlockMenuContent = () => {
const { searchQuery } = useBlockMenuContext();
return (
<div className="flex h-full w-full flex-col">
<BlockMenuSearchBar />
<Separator className="h-[1px] w-full text-zinc-300" />
{searchQuery ? <BlockMenuSearch /> : <BlockMenuDefault />}
</div>
);
};

View File

@@ -0,0 +1,90 @@
import { cn } from "@/lib/utils";
import { Search, X } from "lucide-react";
import React, { useRef, useState, useEffect, useMemo } from "react";
import { useBlockMenuContext } from "./block-menu-provider";
import { Button } from "@/components/ui/button";
import debounce from "lodash/debounce";
import { Input } from "@/components/ui/input";
import { getDefaultFilters } from "./helpers";
const SEARCH_DEBOUNCE_MS = 500;
interface BlockMenuSearchBarProps {
className?: string;
}
export const BlockMenuSearchBar: React.FC<BlockMenuSearchBarProps> = ({
className = "",
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [localQuery, setLocalQuery] = useState("");
const { setSearchQuery, searchId, setSearchId, setFilters } =
useBlockMenuContext();
const searchIdRef = useRef(searchId);
useEffect(() => {
searchIdRef.current = searchId;
}, [searchId]);
const debouncedSetSearchQuery = useMemo(
() =>
debounce((value: string) => {
setSearchQuery(value);
if (value.length === 0) {
setSearchId(undefined);
} else if (!searchIdRef.current) {
setSearchId(crypto.randomUUID());
}
}, SEARCH_DEBOUNCE_MS),
[setSearchQuery, setSearchId],
);
useEffect(() => {
return () => {
debouncedSetSearchQuery.cancel();
};
}, [debouncedSetSearchQuery]);
const handleClear = () => {
setLocalQuery("");
setSearchQuery("");
setSearchId(undefined);
setFilters(getDefaultFilters());
debouncedSetSearchQuery.cancel();
};
return (
<div
className={cn(
"flex min-h-[3.5625rem] items-center gap-2.5 px-4",
className,
)}
>
<Search className="h-6 w-6 text-zinc-700" strokeWidth={2} />
<Input
ref={inputRef}
type="text"
value={localQuery}
onChange={(e) => {
setLocalQuery(e.target.value);
debouncedSetSearchQuery(e.target.value);
}}
placeholder={"Blocks, Agents, Integrations or Keywords..."}
className={cn(
"m-0 border-none p-0 font-sans text-base font-normal text-zinc-800 shadow-none outline-none",
"placeholder:text-zinc-400 focus:shadow-none focus:outline-none focus:ring-0",
)}
/>
{localQuery.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="p-0 hover:bg-transparent"
>
<X className="h-6 w-6 text-zinc-700" strokeWidth={2} />
</Button>
)}
</div>
);
};

View File

@@ -0,0 +1,34 @@
// BLOCK MENU TODO: We need a disable state in this, currently it's not in design.
import { cn } from "@/lib/utils";
import React from "react";
interface Props extends React.HTMLAttributes<HTMLDivElement> {
selected?: boolean;
children?: React.ReactNode; // For icon purpose
disabled?: boolean;
}
export const ControlPanelButton: React.FC<Props> = ({
selected = false,
children,
disabled,
className,
...rest
}) => {
return (
// Using div instead of button, because it's only for design purposes. We are using this to give design to PopoverTrigger.
<div
className={cn(
"flex h-[4.25rem] w-[4.25rem] items-center justify-center whitespace-normal bg-white p-[1.38rem] text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
selected &&
"bg-violet-50 text-violet-700 hover:cursor-default hover:bg-violet-50 hover:text-violet-700 active:bg-violet-50 active:text-violet-700",
disabled && "cursor-not-allowed",
className,
)}
{...rest}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,54 @@
import { Button } from "@/components/ui/button";
import { cn, parseErrorMessage } from "@/lib/utils";
import { AlertCircle, RefreshCw } from "lucide-react";
import React from "react";
interface ErrorStateProps {
title?: string;
message?: string;
error?: string | Error | null;
onRetry?: () => void;
retryLabel?: string;
className?: string;
showIcon?: boolean;
}
export const ErrorState: React.FC<ErrorStateProps> = ({
title = "Something went wrong",
message,
error,
onRetry,
retryLabel = "Retry",
className,
showIcon = true,
}) => {
return (
<div
className={cn(
"flex h-full w-full flex-col items-center justify-center space-y-4 text-center",
className,
)}
>
{showIcon && <AlertCircle className="h-12 w-12" strokeWidth={1.5} />}
<div className="space-y-2">
<p className="text-sm font-medium text-zinc-800">{title}</p>
<p className="text-sm text-zinc-600">
{parseErrorMessage(error, message)}
</p>
</div>
{onRetry && (
<Button
variant="default"
size="sm"
onClick={onRetry}
className="mt-2 h-7 bg-zinc-800 text-xs"
>
<RefreshCw className="mr-1 h-3 w-3" />
{retryLabel}
</Button>
)}
</div>
);
};

View File

@@ -0,0 +1,54 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { X } from "lucide-react";
import React, { ButtonHTMLAttributes } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
selected?: boolean;
number?: number;
name?: string;
}
export const FilterChip: React.FC<Props> = ({
selected = false,
number,
name,
className,
...rest
}) => {
return (
<Button
className={cn(
"group w-fit space-x-1 rounded-[1.5rem] border border-zinc-300 bg-transparent px-[0.625rem] py-[0.375rem] shadow-none transition-transform duration-300 ease-in-out",
"hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
selected && "border-0 bg-violet-700 hover:border",
className,
)}
{...rest}
>
<span
className={cn(
"font-sans text-sm font-medium leading-[1.375rem] text-zinc-600 group-hover:text-zinc-600 group-disabled:text-zinc-400",
selected && "text-zinc-50",
)}
>
{name}
</span>
{selected && (
<>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-zinc-50 transition-all duration-300 ease-in-out group-hover:hidden">
<X
className="h-3 w-3 rounded-full text-violet-700"
strokeWidth={2}
/>
</span>
{number !== undefined && (
<span className="hidden h-[1.375rem] items-center rounded-[1.25rem] bg-violet-700 p-[0.375rem] text-zinc-50 transition-all duration-300 ease-in-out animate-in fade-in zoom-in group-hover:flex">
{number > 100 ? "100+" : number}
</span>
)}
</>
)}
</Button>
);
};

View File

@@ -0,0 +1,88 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { beautifyString, cn } from "@/lib/utils";
import Image from "next/image";
import React, { ButtonHTMLAttributes } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
icon_url?: string;
number_of_blocks?: number;
}
interface IntegrationComponent extends React.FC<Props> {
Skeleton: React.FC<{ className?: string }>;
}
export const Integration: IntegrationComponent = ({
title,
icon_url,
description,
className,
number_of_blocks,
...rest
}) => {
return (
<Button
className={cn(
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-50 active:ring-1 active:ring-zinc-300 disabled:pointer-events-none",
className,
)}
{...rest}
>
<div className="relative h-[2.625rem] w-[2.625rem] overflow-hidden rounded-[0.5rem] bg-white">
{icon_url && (
<Image
src={icon_url}
alt="integration-icon"
fill
sizes="2.25rem"
className="w-full rounded-[0.5rem] object-contain group-disabled:opacity-50"
/>
)}
</div>
<div className="w-full">
<div className="flex items-center justify-between gap-2">
{title && (
<p className="line-clamp-1 flex-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-700 group-disabled:text-zinc-400">
{beautifyString(title)}
</p>
)}
<span className="flex h-[1.375rem] w-[1.6875rem] items-center justify-center rounded-[1.25rem] bg-[#f0f0f0] p-1.5 font-sans text-sm leading-[1.375rem] text-zinc-500 group-disabled:text-zinc-400">
{number_of_blocks}
</span>
</div>
<span className="line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400">
{description}
</span>
</div>
</Button>
);
};
const IntegrationSkeleton: React.FC<{ className?: string }> = ({
className,
}) => {
return (
<Skeleton
className={cn(
"flex h-16 w-full min-w-[7.5rem] animate-pulse items-center justify-start space-x-3 rounded-[0.75rem] bg-zinc-100 px-[0.875rem] py-[0.625rem]",
className,
)}
>
<Skeleton className="h-[2.625rem] w-[2.625rem] rounded-[0.5rem] bg-zinc-200" />
<div className="flex flex-1 flex-col items-start gap-0.5">
<div className="flex w-full items-center justify-between">
<Skeleton className="h-[1.375rem] w-24 rounded bg-zinc-200" />
<Skeleton className="h-[1.375rem] w-[1.6875rem] rounded-[1.25rem] bg-zinc-200" />
</div>
<Skeleton className="h-5 w-[80%] rounded bg-zinc-200" />
</div>
</Skeleton>
);
};
Integration.Skeleton = IntegrationSkeleton;

View File

@@ -0,0 +1,119 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { beautifyString, cn } from "@/lib/utils";
import { Plus } from "lucide-react";
import Image from "next/image";
import React, { ButtonHTMLAttributes } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
icon_url?: string;
highlightedText?: string;
}
interface IntegrationBlockComponent extends React.FC<Props> {
Skeleton: React.FC<{ className?: string }>;
}
export const highlightText = (
text: string | undefined,
highlight: string | undefined,
) => {
if (!text || !highlight) return text;
function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const escaped = escapeRegExp(highlight);
const parts = text.split(new RegExp(`(${escaped})`, "gi"));
return parts.map((part, i) =>
part.toLowerCase() === highlight?.toLowerCase() ? (
<mark key={i} className="bg-transparent font-bold">
{part}
</mark>
) : (
part
),
);
};
export const IntegrationBlock: IntegrationBlockComponent = ({
title,
icon_url,
description,
className,
highlightedText,
...rest
}) => {
return (
<Button
className={cn(
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start gap-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
className,
)}
{...rest}
>
<div className="relative h-[2.625rem] w-[2.625rem] rounded-[0.5rem] bg-white">
{icon_url && (
<Image
src={icon_url}
alt="integration-icon"
fill
sizes="2.25rem"
className="w-full object-contain group-disabled:opacity-50"
/>
)}
</div>
<div className="flex flex-1 flex-col items-start gap-0.5">
{title && (
<span
className={cn(
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 group-disabled:text-zinc-400",
)}
>
{highlightText(beautifyString(title), highlightedText)}
</span>
)}
{description && (
<span
className={cn(
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
{highlightText(description, highlightedText)}
</span>
)}
</div>
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
)}
>
<Plus className="h-5 w-5 text-zinc-50" strokeWidth={2} />
</div>
</Button>
);
};
const IntegrationBlockSkeleton = ({ className }: { className?: string }) => {
return (
<Skeleton
className={cn(
"flex h-16 w-full min-w-[7.5rem] animate-pulse items-center justify-start gap-3 rounded-[0.75rem] bg-zinc-100 px-[0.875rem] py-[0.625rem]",
className,
)}
>
<Skeleton className="h-[2.625rem] w-[2.625rem] rounded-[0.5rem] bg-zinc-200" />
<div className="flex flex-1 flex-col items-start gap-0.5">
<Skeleton className="h-[1.375rem] w-24 rounded bg-zinc-200" />
<Skeleton className="h-5 w-32 rounded bg-zinc-200" />
</div>
<Skeleton className="h-7 w-7 rounded-[0.5rem] bg-zinc-200" />
</Skeleton>
);
};
IntegrationBlock.Skeleton = IntegrationBlockSkeleton;

View File

@@ -0,0 +1,60 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { beautifyString, cn } from "@/lib/utils";
import Image from "next/image";
import React, { ButtonHTMLAttributes } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
name?: string;
icon_url?: string;
}
interface IntegrationChipComponent extends React.FC<Props> {
Skeleton: React.FC;
}
export const IntegrationChip: IntegrationChipComponent = ({
icon_url,
name,
className,
...rest
}) => {
return (
<Button
className={cn(
"flex h-[3.25rem] w-full min-w-[7.5rem] justify-start gap-2 whitespace-normal rounded-[0.5rem] bg-zinc-50 p-2 pr-3 shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300",
className,
)}
{...rest}
>
<div className="relative h-9 w-9 rounded-[0.5rem] bg-transparent">
{icon_url && (
<Image
src={icon_url}
alt="integration-icon"
fill
sizes="2.25rem"
className="w-full object-contain"
/>
)}
</div>
{name && (
<span className="truncate font-sans text-sm font-normal leading-[1.375rem] text-zinc-800">
{beautifyString(name)}
</span>
)}
</Button>
);
};
const IntegrationChipSkeleton: React.FC = () => {
return (
<Skeleton className="flex h-[3.25rem] w-full min-w-[7.5rem] gap-2 rounded-[0.5rem] bg-zinc-100 p-2 pr-3">
<Skeleton className="h-9 w-12 rounded-[0.5rem] bg-zinc-200" />
<Skeleton className="h-5 w-24 self-center rounded-sm bg-zinc-200" />
</Skeleton>
);
};
IntegrationChip.Skeleton = IntegrationChipSkeleton;

View File

@@ -0,0 +1,135 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { ExternalLink, Loader2, Plus } from "lucide-react";
import Image from "next/image";
import React, { ButtonHTMLAttributes } from "react";
import { highlightText } from "./IntegrationBlock";
import Link from "next/link";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
creator_name?: string;
number_of_runs?: number;
image_url?: string;
highlightedText?: string;
slug: string;
loading: boolean;
}
interface MarketplaceAgentBlockComponent extends React.FC<Props> {
Skeleton: React.FC<{ className?: string }>;
}
export const MarketplaceAgentBlock: MarketplaceAgentBlockComponent = ({
title,
image_url,
creator_name,
number_of_runs,
className,
loading,
highlightedText,
slug,
...rest
}) => {
return (
<Button
className={cn(
"group flex h-[4.375rem] w-full min-w-[7.5rem] items-center justify-start gap-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 p-[0.625rem] pr-[0.875rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:pointer-events-none",
className,
)}
{...rest}
>
<div className="relative h-[3.125rem] w-[5.625rem] overflow-hidden rounded-[0.375rem] bg-white">
{image_url && (
<Image
src={image_url}
alt="integration-icon"
fill
sizes="5.625rem"
className="w-full object-contain group-disabled:opacity-50"
/>
)}
</div>
<div className="flex flex-1 flex-col items-start gap-0.5">
{title && (
<span
className={cn(
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 group-disabled:text-zinc-400",
)}
>
{highlightText(title, highlightedText)}
</span>
)}
<div className="flex items-center space-x-2.5">
<span
className={cn(
"truncate font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
By {creator_name}
</span>
<span className="font-sans text-zinc-400"></span>
<span
className={cn(
"truncate font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
{number_of_runs} runs
</span>
<span className="font-sans text-zinc-400"></span>
<Link
href={`/marketplace/agent/${creator_name}/${slug}`}
className="flex gap-0.5 truncate"
onClick={(e) => e.stopPropagation()}
>
<span className="font-sans text-xs leading-5 text-blue-700 underline">
Agent page
</span>
<ExternalLink className="h-4 w-4 text-blue-700" strokeWidth={1} />
</Link>
</div>
</div>
<div
className={cn(
"flex h-7 min-w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
)}
>
{!loading ? (
<Plus className="h-5 w-5 text-zinc-50" strokeWidth={2} />
) : (
<Loader2 className="h-5 w-5 animate-spin" />
)}
</div>
</Button>
);
};
const MarketplaceAgentBlockSkeleton: React.FC<{ className?: string }> = ({
className,
}) => {
return (
<Skeleton
className={cn(
"flex h-[4.375rem] w-full min-w-[7.5rem] animate-pulse items-center justify-start gap-3 rounded-[0.75rem] bg-zinc-100 p-[0.625rem] pr-[0.875rem]",
className,
)}
>
<Skeleton className="h-[3.125rem] w-[5.625rem] rounded-[0.375rem] bg-zinc-200" />
<div className="flex flex-1 flex-col items-start gap-0.5">
<Skeleton className="h-[1.375rem] w-24 rounded bg-zinc-200" />
<div className="flex items-center gap-1">
<Skeleton className="h-5 w-16 rounded bg-zinc-200" />
<Skeleton className="h-5 w-16 rounded bg-zinc-200" />
</div>
</div>
<Skeleton className="h-7 w-7 rounded-[0.5rem] bg-zinc-200" />
</Skeleton>
);
};
MarketplaceAgentBlock.Skeleton = MarketplaceAgentBlockSkeleton;

View File

@@ -0,0 +1,40 @@
// BLOCK MENU TODO: We need to add a better hover state to it; currently it's not in the design either.
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import React, { ButtonHTMLAttributes } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
selected?: boolean;
number?: number;
name?: string;
}
export const MenuItem: React.FC<Props> = ({
selected = false,
number,
name,
className,
...rest
}) => {
return (
<Button
className={cn(
"flex h-[2.375rem] w-[12.875rem] justify-between whitespace-normal rounded-[0.5rem] bg-transparent p-2 pl-3 shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0",
selected && "bg-zinc-100",
className,
)}
{...rest}
>
<span className="truncate font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
{name}
</span>
{number && (
<span className="font-sans text-sm font-normal leading-[1.375rem] text-zinc-600">
{number > 100 ? "100+" : number}
</span>
)}
</Button>
);
};

View File

@@ -0,0 +1,47 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { ArrowUpRight } from "lucide-react";
import React, { ButtonHTMLAttributes } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
content?: string;
}
interface SearchHistoryChipComponent extends React.FC<Props> {
Skeleton: React.FC<{ className?: string }>;
}
export const SearchHistoryChip: SearchHistoryChipComponent = ({
content,
className,
...rest
}) => {
return (
<Button
className={cn(
"my-[1px] h-[2.25rem] space-x-1 rounded-[1.5rem] bg-zinc-50 p-[0.375rem] pr-[0.625rem] shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300",
className,
)}
{...rest}
>
<ArrowUpRight className="h-6 w-6 text-zinc-500" strokeWidth={1.25} />
<span className="font-sans text-sm font-normal leading-[1.375rem] text-zinc-800">
{content}
</span>
</Button>
);
};
const SearchHistoryChipSkeleton: React.FC<{ className?: string }> = ({
className,
}) => {
return (
<Skeleton
className={cn("h-[2.25rem] w-32 rounded-[1.5rem] bg-zinc-100", className)}
/>
);
};
SearchHistoryChip.Skeleton = SearchHistoryChipSkeleton;

View File

@@ -0,0 +1,117 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { Plus } from "lucide-react";
import Image from "next/image";
import React, { ButtonHTMLAttributes } from "react";
import { highlightText } from "./IntegrationBlock";
import TimeAgo from "react-timeago";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
edited_time?: Date;
version?: number;
image_url?: string;
highlightedText?: string;
}
interface UGCAgentBlockComponent extends React.FC<Props> {
Skeleton: React.FC<{ className?: string }>;
}
export const UGCAgentBlock: UGCAgentBlockComponent = ({
title,
image_url,
edited_time,
version,
className,
highlightedText,
...rest
}) => {
return (
<Button
className={cn(
"group flex h-[4.375rem] w-full min-w-[7.5rem] items-center justify-start gap-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 p-[0.625rem] pr-[0.875rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
className,
)}
{...rest}
>
{image_url && (
<div className="relative h-[3.125rem] w-[5.625rem] overflow-hidden rounded-[0.375rem] bg-white">
<Image
src={image_url}
alt="integration-icon"
fill
sizes="5.625rem"
className="w-full object-contain group-disabled:opacity-50"
/>
</div>
)}
<div className="flex flex-1 flex-col items-start gap-0.5">
{title && (
<span
className={cn(
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 group-disabled:text-zinc-400",
)}
>
{highlightText(title, highlightedText)}
</span>
)}
<div className="flex items-center space-x-1.5">
{edited_time && (
<span
className={cn(
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
Edited {<TimeAgo date={edited_time} />}
</span>
)}
<span className="font-sans text-zinc-400"></span>
<span
className={cn(
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
Version {version}
</span>
</div>
</div>
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
)}
>
<Plus className="h-5 w-5 text-zinc-50" strokeWidth={2} />
</div>
</Button>
);
};
const UGCAgentBlockSkeleton: React.FC<{ className?: string }> = ({
className,
}) => {
return (
<Skeleton
className={cn(
"flex h-[4.375rem] w-full min-w-[7.5rem] animate-pulse items-center justify-start gap-3 rounded-[0.75rem] bg-zinc-100 p-[0.625rem] pr-[0.875rem]",
className,
)}
>
<Skeleton className="h-[3.125rem] w-[5.625rem] rounded-[0.375rem] bg-zinc-200" />
<div className="flex flex-1 flex-col items-start gap-0.5">
<Skeleton className="h-[1.375rem] w-24 rounded bg-zinc-200" />
<div className="flex items-center gap-1">
<Skeleton className="h-5 w-16 rounded bg-zinc-200" />
<Skeleton className="h-5 w-16 rounded bg-zinc-200" />
</div>
</div>
<Skeleton className="h-7 w-7 rounded-[0.5rem] bg-zinc-200" />
</Skeleton>
);
};
UGCAgentBlock.Skeleton = UGCAgentBlockSkeleton;

View File

@@ -0,0 +1,176 @@
"use client";
import {
Block,
CredentialsProviderName,
LibraryAgent,
Provider,
StoreAgent,
} from "@/lib/autogpt-server-api";
import { createContext, ReactNode, useContext, useState } from "react";
import { convertLibraryAgentIntoBlock } from "@/lib/utils";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { getDefaultFilters } from "./helpers";
export type SearchItem = Block | Provider | LibraryAgent | StoreAgent;
export type DefaultStateType =
| "suggestion"
| "all_blocks"
| "input_blocks"
| "action_blocks"
| "output_blocks"
| "integrations"
| "marketplace_agents"
| "my_agents";
export type CategoryKey =
| "blocks"
| "integrations"
| "marketplace_agents"
| "my_agents";
export interface Filters {
categories: {
blocks: boolean;
integrations: boolean;
marketplace_agents: boolean;
my_agents: boolean;
providers: boolean;
};
createdBy: string[];
}
export type CategoryCounts = Record<CategoryKey, number>;
interface BlockMenuContextType {
defaultState: DefaultStateType;
setDefaultState: React.Dispatch<React.SetStateAction<DefaultStateType>>;
integration: CredentialsProviderName | null;
setIntegration: React.Dispatch<
React.SetStateAction<CredentialsProviderName | null>
>;
searchQuery: string;
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
searchId: string | undefined;
setSearchId: React.Dispatch<React.SetStateAction<string | undefined>>;
filters: Filters;
setFilters: React.Dispatch<React.SetStateAction<Filters>>;
searchData: SearchItem[];
setSearchData: React.Dispatch<React.SetStateAction<SearchItem[]>>;
categoryCounts: CategoryCounts;
setCategoryCounts: React.Dispatch<React.SetStateAction<CategoryCounts>>;
addNode: (block: Block) => void;
handleAddStoreAgent: ({
creator_name,
slug,
}: {
creator_name: string;
slug: string;
}) => Promise<void>;
loadingSlug: string | null;
setLoadingSlug: React.Dispatch<React.SetStateAction<string | null>>;
}
export const BlockMenuContext = createContext<BlockMenuContextType>(
{} as BlockMenuContextType,
);
interface BlockMenuStateProviderProps {
children: ReactNode;
addNode: (block: Block) => void;
}
export function BlockMenuStateProvider({
children,
addNode,
}: BlockMenuStateProviderProps) {
const [defaultState, setDefaultState] =
useState<DefaultStateType>("suggestion");
const [integration, setIntegration] =
useState<CredentialsProviderName | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<Filters>(getDefaultFilters());
const [searchData, setSearchData] = useState<SearchItem[]>([]);
const [searchId, setSearchId] = useState<string | undefined>(undefined);
const [categoryCounts, setCategoryCounts] = useState<CategoryCounts>({
blocks: 0,
integrations: 0,
marketplace_agents: 0,
my_agents: 0,
});
const [loadingSlug, setLoadingSlug] = useState<string | null>(null);
const api = useBackendAPI();
const handleAddStoreAgent = async ({
creator_name,
slug,
}: {
creator_name: string;
slug: string;
}) => {
try {
setLoadingSlug(slug);
const details = await api.getStoreAgent(creator_name, slug);
if (!details.active_version_id) {
console.error(
"Cannot add store agent to library: active version ID is missing or undefined",
);
return;
}
const libraryAgent = await api.addMarketplaceAgentToLibrary(
details.active_version_id,
);
const block = convertLibraryAgentIntoBlock(libraryAgent);
addNode(block);
} catch (error) {
console.error("Failed to add store agent:", error);
} finally {
setLoadingSlug(null);
}
};
return (
<BlockMenuContext.Provider
value={{
defaultState,
setDefaultState,
integration,
setIntegration,
searchQuery,
setSearchQuery,
searchId,
setSearchId,
filters,
setFilters,
searchData,
setSearchData,
categoryCounts,
setCategoryCounts,
addNode,
handleAddStoreAgent,
loadingSlug,
setLoadingSlug,
}}
>
{children}
</BlockMenuContext.Provider>
);
}
export function useBlockMenuContext(): BlockMenuContextType {
const context = useContext(BlockMenuContext);
if (!context) {
throw new Error(
"useBlockMenuContext must be used within a BlockMenuStateProvider",
);
}
return context;
}

View File

@@ -0,0 +1,162 @@
import React, { useState, useEffect, Fragment, useCallback } from "react";
import { Block } from "../Block";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { BlockCategoryResponse } from "@/lib/autogpt-server-api";
import { useBlockMenuContext } from "../block-menu-provider";
import { ErrorState } from "../ErrorState";
import { beautifyString } from "@/lib/utils";
import { scrollbarStyles } from "@/components/styles/scrollbar";
export const AllBlocksContent = () => {
const { addNode } = useBlockMenuContext();
const [categories, setCategories] = useState<BlockCategoryResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loadingCategories, setLoadingCategories] = useState<Set<string>>(
new Set(),
);
const api = useBackendAPI();
const fetchBlocks = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.getBlockCategories();
setCategories(response);
} catch (err) {
console.error("Failed to fetch block categories:", err);
setError(
err instanceof Error ? err.message : "Failed to load block categories",
);
} finally {
setLoading(false);
}
}, [api]);
useEffect(() => {
fetchBlocks();
}, [fetchBlocks]);
const fetchMoreBlockOfACategory = async (category: string) => {
try {
setLoadingCategories((prev) => new Set(prev).add(category));
const response = await api.getBuilderBlocks({ category: category });
const updatedCategories = categories.map((cat) => {
if (cat.name === category) {
return {
...cat,
blocks: [...response.blocks],
};
}
return cat;
});
setCategories(updatedCategories);
} catch (error) {
console.error(`Failed to fetch blocks for category ${category}:`, error);
} finally {
setLoadingCategories((prev) => {
const newSet = new Set(prev);
newSet.delete(category);
return newSet;
});
}
};
if (loading) {
return (
<div className={scrollbarStyles}>
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 3 }).map((_, categoryIndex) => (
<Fragment key={categoryIndex}>
{categoryIndex > 0 && (
<Skeleton className="my-4 h-[1px] w-full text-zinc-100" />
)}
{[0, 1, 2].map((blockIndex) => (
<Block.Skeleton key={`${categoryIndex}-${blockIndex}`} />
))}
</Fragment>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load blocks"
error={error}
onRetry={fetchBlocks}
/>
</div>
);
}
return (
<div className={scrollbarStyles}>
<div className="w-full space-y-3 px-4 pb-4">
{categories.map((category, index) => (
<Fragment key={category.name}>
{index > 0 && (
<Separator className="h-[1px] w-full text-zinc-300" />
)}
{/* Category Section */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
{category.name && beautifyString(category.name)}
</p>
<span className="rounded-full bg-zinc-100 px-[0.375rem] font-sans text-sm leading-[1.375rem] text-zinc-600">
{category.total_blocks}
</span>
</div>
<div className="space-y-2">
{category.blocks.map((block) => (
<Block
key={`${category.name}-${block.id}`}
title={block.name}
description={block.name}
onClick={() => {
addNode(block);
}}
/>
))}
{loadingCategories.has(category.name) && (
<>
{[0, 1, 2, 3, 4].map((skeletonIndex) => (
<Block.Skeleton
key={`skeleton-${category.name}-${skeletonIndex}`}
/>
))}
</>
)}
{category.total_blocks > category.blocks.length && (
<Button
variant={"link"}
className="px-0 font-sans text-sm leading-[1.375rem] text-zinc-600 underline hover:text-zinc-800"
disabled={loadingCategories.has(category.name)}
onClick={() => {
fetchMoreBlockOfACategory(category.name);
}}
>
see all
</Button>
)}
</div>
</div>
</Fragment>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import React from "react";
import { BlockMenuSidebar } from "./BlockMenuSidebar";
import { Separator } from "@/components/ui/separator";
import { BlockMenuDefaultContent } from "./BlockMenuDefaultContent";
export const BlockMenuDefault = () => {
return (
<div className="flex flex-1 overflow-y-auto">
<BlockMenuSidebar />
<Separator className="h-full w-[1px] text-zinc-300" />
<BlockMenuDefaultContent />
</div>
);
};

View File

@@ -0,0 +1,43 @@
import React from "react";
import { SuggestionContent } from "./SuggestionContent";
import { AllBlocksContent } from "./AllBlocksContent";
import { IntegrationsContent } from "./IntegrationsContent";
import { MarketplaceAgentsContent } from "./MarketplaceAgentsContent";
import { MyAgentsContent } from "./MyAgentsContent";
import { useBlockMenuContext } from "../block-menu-provider";
import { PaginatedBlocksContent } from "./PaginatedBlocksContent";
export interface ActionBlock {
id: number;
title: string;
description: string;
}
export interface BlockListType {
id: number;
title: string;
description: string;
}
export const BlockMenuDefaultContent = () => {
const { defaultState } = useBlockMenuContext();
return (
<div className="h-full flex-1 overflow-hidden">
{defaultState == "suggestion" && <SuggestionContent />}
{defaultState == "all_blocks" && <AllBlocksContent />}
{defaultState == "input_blocks" && (
<PaginatedBlocksContent blockRequest={{ type: "input" }} />
)}
{defaultState == "action_blocks" && (
<PaginatedBlocksContent blockRequest={{ type: "action" }} />
)}
{defaultState == "output_blocks" && (
<PaginatedBlocksContent blockRequest={{ type: "output" }} />
)}
{defaultState == "integrations" && <IntegrationsContent />}
{defaultState == "marketplace_agents" && <MarketplaceAgentsContent />}
{defaultState == "my_agents" && <MyAgentsContent />}
</div>
);
};

View File

@@ -0,0 +1,117 @@
import React, { useEffect, useState } from "react";
import { MenuItem } from "../MenuItem";
import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { CountResponse } from "@/lib/autogpt-server-api";
export const BlockMenuSidebar = () => {
const { defaultState, setDefaultState, setIntegration } =
useBlockMenuContext();
const [blockCounts, setBlockCounts] = useState<CountResponse | undefined>(
undefined,
);
const api = useBackendAPI();
useEffect(() => {
const fetchBlockCounts = async () => {
try {
const counts = await api.getBlockCounts();
setBlockCounts(counts);
} catch (error) {
console.error("Failed to fetch block counts:", error);
}
};
fetchBlockCounts();
}, [api]);
const topLevelMenuItems = [
{
name: "Suggestion",
type: "suggestion",
},
{
name: "All blocks",
type: "all_blocks",
number: blockCounts?.all_blocks,
},
];
const subMenuItems = [
{
name: "Input blocks",
type: "input_blocks",
number: blockCounts?.input_blocks,
},
{
name: "Action blocks",
type: "action_blocks",
number: blockCounts?.action_blocks,
},
{
name: "Output blocks",
type: "output_blocks",
number: blockCounts?.output_blocks,
},
];
const bottomMenuItems = [
{
name: "Integrations",
type: "integrations",
number: blockCounts?.integrations,
onClick: () => {
setIntegration(null);
setDefaultState("integrations");
},
},
{
name: "Marketplace Agents",
type: "marketplace_agents",
number: blockCounts?.marketplace_agents,
},
{
name: "My Agents",
type: "my_agents",
number: blockCounts?.my_agents,
},
];
return (
<div className="w-fit space-y-2 px-4 pt-4">
{topLevelMenuItems.map((item) => (
<MenuItem
key={item.type}
name={item.name}
number={item.number}
selected={defaultState === item.type}
onClick={() => setDefaultState(item.type as DefaultStateType)}
/>
))}
<div className="ml-[0.5365rem] space-y-2 border-l border-black/10 pl-[0.75rem]">
{subMenuItems.map((item) => (
<MenuItem
key={item.type}
name={item.name}
number={item.number}
className="max-w-[11.5339rem]"
selected={defaultState === item.type}
onClick={() => setDefaultState(item.type as DefaultStateType)}
/>
))}
</div>
{bottomMenuItems.map((item) => (
<MenuItem
key={item.type}
name={item.name}
number={item.number}
selected={defaultState === item.type}
onClick={
item.onClick ||
(() => setDefaultState(item.type as DefaultStateType))
}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,34 @@
import React from "react";
import { Block } from "../Block";
import { Block as BlockType } from "@/lib/autogpt-server-api";
import { useBlockMenuContext } from "../block-menu-provider";
interface BlocksListProps {
blocks: BlockType[];
loading?: boolean;
}
export const BlocksList: React.FC<BlocksListProps> = ({
blocks,
loading = false,
}) => {
const { addNode } = useBlockMenuContext();
return (
<div className="w-full space-y-3 px-4 pb-4">
{loading
? Array.from({ length: 7 }).map((_, index) => (
<Block.Skeleton key={index} />
))
: blocks.map((block) => (
<Block
key={block.id}
title={block.name}
description={block.description}
onClick={() => {
addNode(block);
}}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,110 @@
import { Button } from "@/components/ui/button";
import React, { useState, useEffect, Fragment, useCallback } from "react";
import { IntegrationBlock } from "../IntegrationBlock";
import { useBlockMenuContext } from "../block-menu-provider";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { Block } from "@/lib/autogpt-server-api";
import { ErrorState } from "../ErrorState";
import { Skeleton } from "@/components/ui/skeleton";
export const IntegrationBlocks = () => {
const { integration, setIntegration, addNode } = useBlockMenuContext();
const [blocks, setBlocks] = useState<Block[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const api = useBackendAPI();
const fetchBlocks = useCallback(async () => {
if (integration) {
try {
setLoading(true);
setError(null);
const response = await api.getBuilderBlocks({ provider: integration });
setBlocks(response.blocks);
} catch (err) {
console.error("Failed to fetch integration blocks:", err);
setError(
err instanceof Error
? err.message
: "Failed to load integration blocks",
);
} finally {
setLoading(false);
}
}
}, [api, integration]);
useEffect(() => {
fetchBlocks();
}, [fetchBlocks]);
if (loading) {
return (
<div className="w-full space-y-3 p-4">
{Array.from({ length: 3 }).map((_, blockIndex) => (
<Fragment key={blockIndex}>
{blockIndex > 0 && (
<Skeleton className="my-4 h-[1px] w-full text-zinc-100" />
)}
{[0, 1, 2].map((index) => (
<IntegrationBlock.Skeleton key={`${blockIndex}-${index}`} />
))}
</Fragment>
))}
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load integration blocks"
error={error}
onRetry={fetchBlocks}
/>
</div>
);
}
return (
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Button
variant={"link"}
className="p-0 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800"
onClick={() => {
setIntegration(null);
}}
>
Integrations
</Button>
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
/
</p>
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
{integration}
</p>
</div>
<span className="flex h-[1.375rem] w-[1.6875rem] items-center justify-center rounded-[1.25rem] bg-[#f0f0f0] p-1.5 font-sans text-sm leading-[1.375rem] text-zinc-500 group-disabled:text-zinc-400">
{blocks.length}
</span>
</div>
<div className="space-y-3">
{blocks.map((block) => (
<IntegrationBlock
key={block.id}
title={block.name}
description={block.description}
icon_url={`/integrations/${integration}.png`}
onClick={() => {
addNode(block);
}}
/>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,21 @@
import React from "react";
import { PaginatedIntegrationList } from "./PaginatedIntegrationList";
import { IntegrationBlocks } from "./IntegrationBlocks";
import { useBlockMenuContext } from "../block-menu-provider";
import { scrollbarStyles } from "@/components/styles/scrollbar";
export const IntegrationsContent = () => {
const { integration } = useBlockMenuContext();
if (!integration) {
return <PaginatedIntegrationList />;
}
return (
<div className={scrollbarStyles}>
<div className="w-full px-4 pb-4">
<IntegrationBlocks />
</div>
</div>
);
};

View File

@@ -0,0 +1,77 @@
import React from "react";
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
import { usePagination } from "@/hooks/usePagination";
import { ErrorState } from "../ErrorState";
import { useBlockMenuContext } from "../block-menu-provider";
import { scrollbarStyles } from "@/components/styles/scrollbar";
export const MarketplaceAgentsContent = () => {
const {
data: agents,
loading,
loadingMore,
hasMore,
error,
scrollRef,
refresh,
} = usePagination({
request: { apiType: "store-agents" },
pageSize: 10,
});
const { handleAddStoreAgent, loadingSlug } = useBlockMenuContext();
if (loading) {
return (
<div ref={scrollRef} className={scrollbarStyles}>
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 5 }).map((_, index) => (
<MarketplaceAgentBlock.Skeleton key={index} />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load marketplace agents"
error={error}
onRetry={refresh}
/>
</div>
);
}
return (
<div ref={scrollRef} className={scrollbarStyles}>
<div className="w-full space-y-3 px-4 pb-4">
{agents.map((agent) => (
<MarketplaceAgentBlock
key={agent.slug}
slug={agent.slug}
title={agent.agent_name}
image_url={agent.agent_image}
creator_name={agent.creator}
number_of_runs={agent.runs}
loading={loadingSlug === agent.slug}
onClick={() =>
handleAddStoreAgent({
creator_name: agent.creator,
slug: agent.slug,
})
}
/>
))}
{loadingMore && hasMore && (
<>
{Array.from({ length: 3 }).map((_, index) => (
<MarketplaceAgentBlock.Skeleton key={`loading-${index}`} />
))}
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,74 @@
import React from "react";
import { UGCAgentBlock } from "../UGCAgentBlock";
import { usePagination } from "@/hooks/usePagination";
import { ErrorState } from "../ErrorState";
import { useBlockMenuContext } from "../block-menu-provider";
import { convertLibraryAgentIntoBlock } from "@/lib/utils";
import { scrollbarStyles } from "@/components/styles/scrollbar";
export const MyAgentsContent = () => {
const {
data: agents,
loading,
loadingMore,
hasMore,
error,
scrollRef,
refresh,
} = usePagination({
request: { apiType: "library-agents" },
pageSize: 10,
});
const { addNode } = useBlockMenuContext();
if (loading) {
return (
<div ref={scrollRef} className={scrollbarStyles}>
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 5 }).map((_, index) => (
<UGCAgentBlock.Skeleton key={index} />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load library agents"
error={error}
onRetry={refresh}
/>
</div>
);
}
return (
<div ref={scrollRef} className={scrollbarStyles}>
<div className="w-full space-y-3 px-4 pb-4">
{agents.map((agent) => (
<UGCAgentBlock
key={agent.id}
title={agent.name}
edited_time={agent.updated_at}
version={agent.graph_version}
image_url={agent.image_url}
onClick={() => {
const block = convertLibraryAgentIntoBlock(agent);
addNode(block);
}}
/>
))}
{loadingMore && hasMore && (
<>
{Array.from({ length: 3 }).map((_, index) => (
<UGCAgentBlock.Skeleton key={`loading-${index}`} />
))}
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,55 @@
import React from "react";
import { BlocksList } from "./BlocksList";
import { Block } from "../Block";
import { BlockRequest } from "@/lib/autogpt-server-api";
import { usePagination } from "@/hooks/usePagination";
import { ErrorState } from "../ErrorState";
import { scrollbarStyles } from "@/components/styles/scrollbar";
interface PaginatedBlocksContentProps {
blockRequest: BlockRequest;
pageSize?: number;
}
export const PaginatedBlocksContent: React.FC<PaginatedBlocksContentProps> = ({
blockRequest,
pageSize = 10,
}) => {
const {
data: blocks,
loading,
loadingMore,
hasMore,
error,
scrollRef,
refresh,
} = usePagination({
request: { apiType: "blocks", ...blockRequest },
pageSize,
});
if (error) {
return (
<div className="h-full w-full px-4 pb-4">
<ErrorState
title="Failed to load blocks"
error={error}
onRetry={refresh}
/>
</div>
);
}
return (
<div ref={scrollRef} className={scrollbarStyles}>
<BlocksList blocks={blocks} loading={loading} />
{loadingMore && hasMore && (
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 3 }).map((_, index) => (
<Block.Skeleton key={`loading-${index}`} />
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,72 @@
import React from "react";
import { Integration } from "../Integration";
import { useBlockMenuContext } from "../block-menu-provider";
import { usePagination } from "@/hooks/usePagination";
import { ErrorState } from "../ErrorState";
import { scrollbarStyles } from "@/components/styles/scrollbar";
export const PaginatedIntegrationList = () => {
const { setIntegration } = useBlockMenuContext();
const {
data: providers,
loading,
loadingMore,
hasMore,
error,
scrollRef,
refresh,
} = usePagination({
request: { apiType: "providers" },
pageSize: 10,
});
if (loading) {
return (
<div ref={scrollRef} className={scrollbarStyles}>
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 6 }).map((_, integrationIndex) => (
<Integration.Skeleton key={integrationIndex} />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load integrations"
error={error}
onRetry={refresh}
/>
</div>
);
}
return (
<div ref={scrollRef} className={scrollbarStyles}>
<div className="w-full px-4 pb-4">
<div className="space-y-3">
{providers.map((integration, index) => (
<Integration
key={index}
title={integration.name}
icon_url={`/integrations/${integration.name}.png`}
description={integration.description}
number_of_blocks={integration.integration_count}
onClick={() => setIntegration(integration.name)}
/>
))}
{loadingMore && hasMore && (
<>
{Array.from({ length: 3 }).map((_, index) => (
<Integration.Skeleton key={`loading-${index}`} />
))}
</>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,113 @@
import React, { useCallback, useEffect, useState } from "react";
import { IntegrationChip } from "../IntegrationChip";
import { Block } from "../Block";
import { useBlockMenuContext } from "../block-menu-provider";
import {
CredentialsProviderName,
SuggestionsResponse,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { ErrorState } from "../ErrorState";
import { scrollbarStyles } from "@/components/styles/scrollbar";
export const SuggestionContent = () => {
const { setIntegration, setDefaultState, addNode } = useBlockMenuContext();
const [suggestionsData, setSuggestionsData] =
useState<SuggestionsResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const api = useBackendAPI();
const fetchSuggestions = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.getSuggestions();
setSuggestionsData(response);
} catch (err) {
console.error("Error fetching data:", err);
setError(
err instanceof Error ? err.message : "Failed to load suggestions",
);
} finally {
setLoading(false);
}
}, [api]);
useEffect(() => {
fetchSuggestions();
}, [fetchSuggestions]);
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load suggestions"
error={error}
onRetry={fetchSuggestions}
/>
</div>
);
}
return (
<div className={scrollbarStyles}>
<div className="w-full space-y-6 pb-4">
{/* Integrations */}
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Integrations
</p>
<div className="grid grid-cols-3 grid-rows-2 gap-2">
{!loading && suggestionsData
? suggestionsData.providers.map((provider, index) => (
<IntegrationChip
key={`integration-${index}`}
icon_url={`/integrations/${provider}.png`}
name={provider}
onClick={() => {
setDefaultState("integrations");
setIntegration(provider as CredentialsProviderName);
}}
/>
))
: Array(6)
.fill(0)
.map((_, index) => (
<IntegrationChip.Skeleton
key={`integration-skeleton-${index}`}
/>
))}
</div>
</div>
{/* Top blocks */}
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Top blocks
</p>
<div className="space-y-2">
{!loading && suggestionsData
? suggestionsData.top_blocks.map((block, index) => (
<Block
key={`block-${index}`}
title={block.name}
description={block.description}
onClick={() => {
addNode(block);
}}
/>
))
: Array(3)
.fill(0)
.map((_, index) => (
<Block.Skeleton key={`block-skeleton-${index}`} />
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,12 @@
import { Filters } from "./block-menu-provider";
export const getDefaultFilters = (): Filters => ({
categories: {
blocks: false,
integrations: false,
marketplace_agents: false,
my_agents: false,
providers: false,
},
createdBy: [],
});

View File

@@ -0,0 +1,63 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Plus } from "lucide-react";
import { ButtonHTMLAttributes } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
ai_name?: string;
}
export const AiBlock: React.FC<Props> = ({
title,
description,
className,
ai_name,
...rest
}) => {
return (
<Button
className={cn(
"group flex h-[5.625rem] w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:pointer-events-none",
className,
)}
{...rest}
>
<div className="flex flex-1 flex-col items-start gap-1.5">
<div className="space-y-0.5">
<span
className={cn(
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-700 group-disabled:text-zinc-400",
)}
>
{title}
</span>
<span
className={cn(
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
{description}
</span>
</div>
<span
className={cn(
"rounded-[0.75rem] bg-zinc-200 px-[0.5rem] font-sans text-xs leading-[1.25rem] text-zinc-500",
)}
>
Supports {ai_name}
</span>
</div>
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
)}
>
<Plus className="h-5 w-5 text-zinc-50" strokeWidth={2} />
</div>
</Button>
);
};

View File

@@ -0,0 +1,144 @@
import React, { useEffect, useState, useCallback, useRef } from "react";
import { FiltersList } from "./FiltersList";
import { SearchList } from "./SearchList";
import { useBlockMenuContext } from "../block-menu-provider";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { cn } from "@/lib/utils";
import { scrollbarStyles } from "@/components/styles/scrollbar";
export const BlockMenuSearch = () => {
const {
searchData,
searchQuery,
searchId,
setSearchData,
filters,
setCategoryCounts,
} = useBlockMenuContext();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(true);
const [page, setPage] = useState<number>(1);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const api = useBackendAPI();
const pageSize = 10;
const fetchSearchData = useCallback(
async (pageNum: number, isLoadMore: boolean = false) => {
if (isLoadMore) {
setLoadingMore(true);
} else {
setIsLoading(true);
}
try {
const activeCategories = Object.entries(filters.categories)
.filter(([_, isActive]) => isActive)
.map(([category, _]) => category)
.map(
(category) =>
category as
| "blocks"
| "integrations"
| "marketplace_agents"
| "my_agents",
);
const response = await api.searchBlocks({
search_query: searchQuery,
search_id: searchId,
page: pageNum,
page_size: pageSize,
filter: activeCategories.length > 0 ? activeCategories : undefined,
by_creator:
filters.createdBy.length > 0 ? filters.createdBy : undefined,
});
setCategoryCounts(response.total_items);
if (isLoadMore) {
setSearchData((prev) => [...prev, ...response.items]);
} else {
setSearchData(response.items);
}
setHasMore(response.more_pages);
setError(null);
} catch (error) {
console.error("Error fetching search data:", error);
setError(
error instanceof Error
? error.message
: "Failed to load search results",
);
if (!isLoadMore) {
setPage(1);
}
} finally {
setIsLoading(false);
setLoadingMore(false);
}
},
[
searchQuery,
searchId,
filters,
api,
setCategoryCounts,
setSearchData,
pageSize,
],
);
const handleScroll = useCallback(() => {
if (!scrollRef.current || loadingMore || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
if (scrollTop + clientHeight >= scrollHeight - 100) {
const nextPage = page + 1;
setPage(nextPage);
fetchSearchData(nextPage, true);
}
}, [loadingMore, hasMore, page, fetchSearchData]);
useEffect(() => {
const scrollElement = scrollRef.current;
if (scrollElement) {
scrollElement.addEventListener("scroll", handleScroll);
return () => scrollElement.removeEventListener("scroll", handleScroll);
}
}, [handleScroll]);
useEffect(() => {
if (searchQuery) {
setPage(1);
setHasMore(true);
setError(null);
fetchSearchData(1, false);
} else {
setSearchData([]);
setError(null);
setPage(1);
setHasMore(true);
}
}, [searchQuery, searchId, filters, fetchSearchData, setSearchData]);
return (
<div ref={scrollRef} className={cn(scrollbarStyles, "space-y-4 py-4")}>
{searchData.length !== 0 && <FiltersList />}
<SearchList
isLoading={isLoading}
loadingMore={loadingMore}
hasMore={hasMore}
error={error}
onRetry={() => {
setPage(1);
setError(null);
fetchSearchData(1, false);
}}
/>
</div>
);
};

View File

@@ -0,0 +1,255 @@
import { FilterChip } from "../FilterChip";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { cn, getBlockType } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import {
CategoryKey,
Filters,
useBlockMenuContext,
} from "../block-menu-provider";
import { StoreAgent } from "@/lib/autogpt-server-api";
import { getDefaultFilters } from "../helpers";
import { scrollbarStyles } from "@/components/styles/scrollbar";
const INITIAL_CREATORS_TO_SHOW = 5;
export function FilterSheet({
categories,
}: {
categories: Array<{ key: CategoryKey; name: string }>;
}) {
const { filters, setFilters, searchData } = useBlockMenuContext();
const [isOpen, setIsOpen] = useState(false);
const [isSheetVisible, setIsSheetVisible] = useState(false);
const [localFilters, setLocalFilters] = useState<Filters>(filters);
const [creators, setCreators] = useState<string[]>([]);
const [displayedCreatorsCount, setDisplayedCreatorsCount] = useState(
INITIAL_CREATORS_TO_SHOW,
);
useEffect(() => {
if (isOpen) {
setIsSheetVisible(true);
setLocalFilters(filters);
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW); // Reset on open
const marketplaceAgents = (searchData?.filter(
(item) => getBlockType(item) === "store_agent",
) || []) as StoreAgent[];
const uniqueCreators = Array.from(
new Set(marketplaceAgents.map((agent) => agent.creator)),
);
setCreators(uniqueCreators);
} else {
const timer = setTimeout(() => {
setIsSheetVisible(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen, filters, searchData]);
const onCategoryChange = (category: CategoryKey) => {
setLocalFilters((prev) => ({
...prev,
categories: {
...prev.categories,
[category]: !prev.categories[category],
},
}));
};
const onCreatorChange = (creator: string) => {
setLocalFilters((prev) => {
const updatedCreators = prev.createdBy.includes(creator)
? prev.createdBy.filter((c) => c !== creator)
: [...prev.createdBy, creator];
return {
...prev,
createdBy: updatedCreators,
};
});
};
const handleApplyFilters = () => {
setFilters(localFilters);
setIsOpen(false);
};
const handleClearFilters = () => {
const clearedFilters: Filters = getDefaultFilters();
setFilters(clearedFilters);
setIsOpen(false);
};
const hasLocalActiveFilters = () => {
const hasCategoryFilter = Object.values(localFilters.categories).some(
(value) => value,
);
const hasCreatorFilter = localFilters.createdBy.length > 0;
return hasCategoryFilter || hasCreatorFilter;
};
const hasActiveFilters = () => {
const hasCategoryFilter = Object.values(filters.categories).some(
(value) => value,
);
const hasCreatorFilter = filters.createdBy.length > 0;
return hasCategoryFilter || hasCreatorFilter;
};
const handleToggleShowMoreCreators = () => {
if (displayedCreatorsCount < creators.length) {
setDisplayedCreatorsCount(creators.length);
} else {
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
}
};
const visibleCreators = creators.slice(0, displayedCreatorsCount);
return (
<div className="m-0 ml-4 inline w-fit p-0">
<Button
onClick={() => {
setIsSheetVisible(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsOpen(true);
});
});
}}
variant={"link"}
className="m-0 p-0 hover:no-underline"
>
<FilterChip
name={hasActiveFilters() ? "Edit filters" : "All filters"}
/>
</Button>
{isSheetVisible && (
<>
<div
className={cn(
"absolute bottom-2 left-2 top-2 z-20 w-3/4 max-w-[22.5rem] space-y-4 overflow-hidden rounded-[0.75rem] bg-white pb-4 shadow-[0_4px_12px_2px_rgba(0,0,0,0.1)] transition-all",
isOpen
? "translate-x-0 duration-300 ease-out"
: "-translate-x-full duration-300 ease-out",
)}
>
<div className={cn("flex-1 space-y-4 pb-16", scrollbarStyles)}>
{/* Top */}
<div className="flex items-center justify-between px-5">
<p className="font-sans text-base text-[#040404]">Filters</p>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
<Separator className="h-[1px] w-full text-zinc-300" />
{/* Categories */}
<div className="space-y-4 px-5">
<p className="font-sans text-base font-medium text-zinc-800">
Categories
</p>
<div className="space-y-2">
{categories.map((category) => (
<div
key={category.key}
className="flex items-center space-x-2"
>
<Checkbox
id={category.key}
checked={localFilters.categories[category.key]}
onCheckedChange={() => onCategoryChange(category.key)}
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
/>
<label
htmlFor={category.key}
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
>
{category.name}
</label>
</div>
))}
</div>
</div>
<Separator className="h-[1px] w-full text-zinc-300" />
{/* Created By */}
<div className="space-y-4 px-5">
<p className="font-sans text-base font-medium text-zinc-800">
Created by
</p>
<div className="space-y-2">
{visibleCreators.map((creator) => (
<div key={creator} className="flex items-center space-x-2">
<Checkbox
id={`creator-${creator}`}
checked={localFilters.createdBy.includes(creator)}
onCheckedChange={() => onCreatorChange(creator)}
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
/>
<label
htmlFor={`creator-${creator}`}
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
>
{creator}
</label>
</div>
))}
</div>
{creators.length > INITIAL_CREATORS_TO_SHOW && (
<Button
variant={"link"}
className="m-0 p-0 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 underline hover:text-zinc-600"
onClick={handleToggleShowMoreCreators}
>
{displayedCreatorsCount < creators.length ? "More" : "Less"}
</Button>
)}
</div>
</div>
{/* Footer buttons */}
<div className="fixed bottom-0 flex w-full justify-between gap-3 border-t border-zinc-300 bg-white px-5 py-3">
<Button
className="min-w-[5rem] rounded-[0.5rem] border-none px-1.5 py-2 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 shadow-none ring-1 ring-zinc-400"
variant={"outline"}
onClick={handleClearFilters}
>
Clear
</Button>
<Button
className={cn(
"min-w-[6.25rem] rounded-[0.5rem] border-none px-1.5 py-2 font-sans text-sm font-medium leading-[1.375rem] text-white shadow-none ring-1 disabled:ring-0",
)}
onClick={handleApplyFilters}
disabled={!hasLocalActiveFilters()}
>
Apply filters
</Button>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { useCallback } from "react";
import { FilterChip } from "../FilterChip";
import { FilterSheet } from "./FilterSheet";
import { CategoryKey, useBlockMenuContext } from "../block-menu-provider";
export const FiltersList = () => {
const { filters, setFilters, categoryCounts } = useBlockMenuContext();
const categories: Array<{ key: CategoryKey; name: string }> = [
{ key: "blocks", name: "Blocks" },
{ key: "integrations", name: "Integrations" },
{ key: "marketplace_agents", name: "Marketplace agents" },
{ key: "my_agents", name: "My agents" },
];
const handleCategoryFilter = (category: CategoryKey) => {
setFilters({
...filters,
categories: {
...filters.categories,
[category]: !filters.categories[category],
},
});
};
const handleCreatorFilter = useCallback(
(creator: string) => {
const updatedCreators = filters.createdBy.includes(creator)
? filters.createdBy.filter((c) => c !== creator)
: [...filters.createdBy, creator];
setFilters({
...filters,
createdBy: updatedCreators,
});
},
[filters, setFilters],
);
return (
<div className="flex flex-nowrap gap-3 overflow-x-auto scrollbar-hide">
<FilterSheet categories={categories} />
{filters.createdBy.map((creator) => (
<FilterChip
key={creator}
name={"Created by " + creator}
selected={true}
onClick={() => handleCreatorFilter(creator)}
/>
))}
{categories.map((category) => (
<FilterChip
key={category.key}
name={category.name}
number={categoryCounts[category.key]}
selected={filters.categories[category.key]}
onClick={() => handleCategoryFilter(category.key)}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,17 @@
import { Frown } from "lucide-react";
export const NoSearchResult = () => {
return (
<div className="flex h-full w-full flex-col items-center justify-center text-center">
<Frown className="mb-10 h-16 w-16 text-zinc-400" strokeWidth={1} />
<div className="space-y-1">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
No match found
</p>
<p className="font-sans text-sm font-normal leading-[1.375rem] text-zinc-600">
Try adjusting your search terms
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,171 @@
import React from "react";
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
import { Block } from "../Block";
import { UGCAgentBlock } from "../UGCAgentBlock";
import { AiBlock } from "./AiBlock";
import { IntegrationBlock } from "../IntegrationBlock";
import { useBlockMenuContext } from "../block-menu-provider";
import { NoSearchResult } from "./NoSearchResult";
import { Button } from "@/components/ui/button";
import { convertLibraryAgentIntoBlock, getBlockType } from "@/lib/utils";
interface SearchListProps {
isLoading: boolean;
loadingMore: boolean;
hasMore: boolean;
error: string | null;
onRetry: () => void;
}
export const SearchList: React.FC<SearchListProps> = ({
isLoading,
loadingMore,
hasMore,
error,
onRetry,
}) => {
const { searchQuery, addNode, loadingSlug, searchData, handleAddStoreAgent } =
useBlockMenuContext();
if (isLoading) {
return (
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Search results
</p>
{Array(6)
.fill(0)
.map((_, i) => (
<Block.Skeleton key={i} />
))}
</div>
);
}
if (error) {
return (
<div className="px-4">
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<p className="mb-2 text-sm text-red-600">
Error loading search results: {error}
</p>
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="h-7 text-xs"
>
Retry
</Button>
</div>
</div>
);
}
if (searchData.length === 0) {
return <NoSearchResult />;
}
return (
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Search results
</p>
{searchData.map((item: any, index: number) => {
const blockType = getBlockType(item);
switch (blockType) {
case "store_agent":
return (
<MarketplaceAgentBlock
key={index}
slug={item.slug}
highlightedText={searchQuery}
title={item.agent_name}
image_url={item.agent_image}
creator_name={item.creator}
number_of_runs={item.runs}
loading={loadingSlug == item.slug}
onClick={() =>
handleAddStoreAgent({
creator_name: item.creator,
slug: item.slug,
})
}
/>
);
case "block":
return (
<Block
key={index}
title={item.name}
highlightedText={searchQuery}
description={item.description}
onClick={() => {
addNode(item);
}}
/>
);
case "provider":
return (
<IntegrationBlock
key={index}
title={item.name}
highlightedText={searchQuery}
icon_url={`/integrations/${item.name}.png`}
description={item.description}
onClick={() => {
addNode(item);
}}
/>
);
case "library_agent":
return (
<UGCAgentBlock
key={index}
title={item.name}
highlightedText={searchQuery}
image_url={item.image_url}
version={item.graph_version}
edited_time={item.updated_at}
onClick={() => {
const block = convertLibraryAgentIntoBlock(item);
addNode(block);
}}
/>
);
case "ai_agent":
return (
<AiBlock
key={index}
title={item.name}
description={item.description}
ai_name={item.inputSchema.properties.model.enum.find(
(model: string) =>
model
.toLowerCase()
.includes(searchQuery.toLowerCase().trim()),
)}
onClick={() => {
const block = convertLibraryAgentIntoBlock(item);
addNode(block);
}}
/>
);
default:
return null;
}
})}
{loadingMore && hasMore && (
<div className="space-y-2.5">
{Array(3)
.fill(0)
.map((_, i) => (
<Block.Skeleton key={`loading-more-${i}`} />
))}
</div>
)}
</div>
);
};

View File

@@ -1,13 +1,7 @@
import { Card, CardContent } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import React from "react";
import { ControlPanelButton } from "@/components/builder/block-menu/ControlPanelButton";
/**
* Represents a control element for the ControlPanel Component.
@@ -27,6 +21,7 @@ interface ControlPanelProps {
controls: Control[];
topChildren?: React.ReactNode;
botChildren?: React.ReactNode;
className?: string;
}
@@ -45,42 +40,31 @@ export const ControlPanel = ({
className,
}: ControlPanelProps) => {
return (
<Card className={cn("m-4 mt-24 w-14 dark:bg-slate-900", className)}>
<CardContent className="p-0">
<div className="flex flex-col items-center gap-3 rounded-xl py-3">
{topChildren}
<Separator className="dark:bg-slate-700" />
{controls.map((control, index) => (
<Tooltip key={index} delayDuration={500}>
<TooltipTrigger asChild>
<div>
<Button
variant="ghost"
size="icon"
onClick={() => control.onClick()}
data-id={`control-button-${index}`}
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
disabled={control.disabled || false}
className="dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="dark:bg-slate-800 dark:text-slate-100"
>
{control.label}
</TooltipContent>
</Tooltip>
))}
<Separator className="dark:bg-slate-700" />
{botChildren}
</div>
</CardContent>
</Card>
<section
className={cn(
"absolute left-4 top-24 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
className,
)}
>
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
{topChildren}
<Separator className="text-[#E1E1E1]" />
{controls.map((control, index) => (
<ControlPanelButton
key={index}
onClick={() => control.onClick()}
data-id={`control-button-${index}`}
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
disabled={control.disabled || false}
className="rounded-none"
>
{control.icon}
</ControlPanelButton>
))}
<Separator className="text-[#E1E1E1]" />
{botChildren}
</div>
</section>
);
};
export default ControlPanel;

View File

@@ -10,12 +10,8 @@ import { Button } from "@/components/ui/button";
import { GraphMeta } from "@/lib/autogpt-server-api";
import { Label } from "@/components/ui/label";
import { IconSave } from "@/components/ui/icons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useToast } from "@/components/ui/use-toast";
import { ControlPanelButton } from "@/components/builder/block-menu/ControlPanelButton";
interface SaveControlProps {
agentMeta: GraphMeta | null;
@@ -26,6 +22,11 @@ interface SaveControlProps {
onNameChange: (name: string) => void;
onDescriptionChange: (description: string) => void;
pinSavePopover: boolean;
blockMenuSelected: "save" | "block" | "";
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block">
>;
}
/**
@@ -48,6 +49,8 @@ export const SaveControl = ({
onNameChange,
agentDescription,
onDescriptionChange,
blockMenuSelected,
setBlockMenuSelected,
pinSavePopover,
}: SaveControlProps) => {
/**
@@ -82,27 +85,29 @@ export const SaveControl = ({
}, [handleSave, toast]);
return (
<Popover open={pinSavePopover ? true : undefined}>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
data-id="save-control-popover-trigger"
data-testid="blocks-control-save-button"
name="Save"
>
<IconSave className="dark:text-gray-300" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">Save</TooltipContent>
</Tooltip>
<Popover
open={pinSavePopover ? true : undefined}
onOpenChange={(open) => open || setBlockMenuSelected("")}
>
<PopoverTrigger>
<ControlPanelButton
data-id="save-control-popover-trigger"
data-testid="blocks-control-save-button"
selected={blockMenuSelected === "save"}
onClick={() => {
setBlockMenuSelected("save");
}}
className="rounded-none"
>
<IconSave className="h-5 w-5" strokeWidth={2} />
</ControlPanelButton>
</PopoverTrigger>
<PopoverContent
side="right"
sideOffset={15}
sideOffset={16}
align="start"
className="w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
data-id="save-control-popover-content"
>
<Card className="border-none shadow-none dark:bg-slate-900">

View File

@@ -0,0 +1,2 @@
export const scrollbarStyles =
"scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200";

View File

@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-neutral-200 border-neutral-900 shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 dark:border-neutral-50 dark:border-neutral-800 dark:focus-visible:ring-neutral-300 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=checked]:text-neutral-900",
"peer h-4 w-4 shrink-0 rounded-sm border border-neutral-900 shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 dark:border-neutral-50 dark:border-neutral-800 dark:focus-visible:ring-neutral-300 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=checked]:text-neutral-900",
className,
)}
{...props}
@@ -21,7 +21,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<CheckIcon className="h-4 w-4" />
<CheckIcon className="h-4 w-4" strokeWidth={2} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));

View File

@@ -256,7 +256,7 @@ const MultiSelectorList = forwardRef<
<CommandList
ref={ref}
className={cn(
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg absolute top-0 z-10 flex w-full flex-col gap-2 rounded-md border border-muted bg-background p-2 shadow-md transition-colors",
"scrollbar-thumb-rounded-lg absolute top-0 z-10 flex w-full flex-col gap-2 rounded-md border border-muted bg-background p-2 shadow-md transition-colors scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted",
className,
)}
>

View File

@@ -0,0 +1 @@
export { usePagination } from "./usePagination";

View File

@@ -14,6 +14,7 @@ import BackendAPI, {
GraphMeta,
NodeExecutionResult,
SpecialBlockID,
Node,
} from "@/lib/autogpt-server-api";
import {
deepEquals,
@@ -177,6 +178,16 @@ export default function useAgentGraph(
setAgentName(graph.name);
setAgentDescription(graph.description);
const getGraphName = (node: Node) => {
if (node.input_default.agent_name) {
return node.input_default.agent_name;
}
return (
availableFlows.find((flow) => flow.id === node.input_default.graph_id)
?.name || null
);
};
setNodes((prevNodes) => {
const _newNodes = graph.nodes.map((node) => {
const block = availableNodes.find(
@@ -184,12 +195,8 @@ export default function useAgentGraph(
)!;
if (!block) return null;
const prevNode = prevNodes.find((n) => n.id === node.id);
const flow =
block.uiType == BlockUIType.AGENT
? availableFlows.find(
(flow) => flow.id === node.input_default.graph_id,
)
: null;
const graphName =
(block.uiType == BlockUIType.AGENT && getGraphName(node)) || null;
const newNode: CustomNode = {
id: node.id,
type: "custom",
@@ -201,7 +208,7 @@ export default function useAgentGraph(
isOutputOpen: false,
...prevNode?.data,
block_id: block.id,
blockType: flow?.name || block.name,
blockType: graphName || block.name,
blockCosts: block.costs,
categories: block.categories,
description: block.description,
@@ -281,15 +288,17 @@ export default function useAgentGraph(
const getToolFuncName = (nodeId: string) => {
const sinkNode = nodes.find((node) => node.id === nodeId);
const sinkNodeName = sinkNode
? sinkNode.data.block_id === SpecialBlockID.AGENT
? sinkNode.data.hardcodedValues?.graph_id
? availableFlows.find(
(flow) => flow.id === sinkNode.data.hardcodedValues.graph_id,
)?.name || "agentexecutorblock"
: "agentexecutorblock"
: sinkNode.data.title.split(" ")[0]
: "";
if (!sinkNode) return "";
const sinkNodeName =
sinkNode.data.block_id === SpecialBlockID.AGENT
? sinkNode.data.hardcodedValues?.agent_name ||
availableFlows.find(
(flow) => flow.id === sinkNode.data.hardcodedValues.graph_id,
)?.name ||
"agentexecutorblock"
: sinkNode.data.title.split(" ")[0];
return sinkNodeName;
};
@@ -1120,7 +1129,6 @@ export default function useAgentGraph(
setAgentDescription,
savedAgent,
availableNodes,
availableFlows,
getOutputType,
requestSave,
requestSaveAndRun,

View File

@@ -0,0 +1,232 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
Block,
BlockRequest,
Provider,
StoreAgent,
LibraryAgent,
LibraryAgentSortEnum,
} from "@/lib/autogpt-server-api";
type BlocksPaginationRequest = { apiType: "blocks" } & BlockRequest;
type ProvidersPaginationRequest = { apiType: "providers" } & {
page?: number;
page_size?: number;
};
type StoreAgentsPaginationRequest = { apiType: "store-agents" } & {
featured?: boolean;
creator?: string;
sorted_by?: string;
search_query?: string;
category?: string;
page?: number;
page_size?: number;
};
type LibraryAgentsPaginationRequest = { apiType: "library-agents" } & {
search_term?: string;
sort_by?: LibraryAgentSortEnum;
page?: number;
page_size?: number;
};
type PaginationRequest =
| BlocksPaginationRequest
| ProvidersPaginationRequest
| StoreAgentsPaginationRequest
| LibraryAgentsPaginationRequest;
interface UsePaginationOptions<T extends PaginationRequest> {
request: T;
pageSize?: number;
enabled?: boolean;
}
interface UsePaginationReturn<T> {
data: T[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
error: string | null;
scrollRef: React.RefObject<HTMLDivElement>;
refresh: () => void;
loadMore: () => void;
}
type GetReturnType<T> = T extends BlocksPaginationRequest
? Block
: T extends ProvidersPaginationRequest
? Provider
: T extends StoreAgentsPaginationRequest
? StoreAgent
: T extends LibraryAgentsPaginationRequest
? LibraryAgent
: never;
export const usePagination = <T extends PaginationRequest>({
request,
pageSize = 10,
enabled = true, // to allow pagination or not
}: UsePaginationOptions<T>): UsePaginationReturn<GetReturnType<T>> => {
const [data, setData] = useState<GetReturnType<T>[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const isLoadingRef = useRef(false);
const requestRef = useRef(request);
const api = useBackendAPI();
// because we are using this pagination for multiple components
requestRef.current = request;
const fetchData = useCallback(
async (page: number, isLoadMore = false) => {
if (isLoadingRef.current || !enabled) return;
isLoadingRef.current = true;
if (isLoadMore) {
setLoadingMore(true);
} else {
setLoading(true);
}
setError(null);
try {
let response;
let newData: GetReturnType<T>[];
let pagination;
const currentRequest = requestRef.current;
const requestWithPagination = {
...currentRequest,
page,
page_size: pageSize,
};
switch (currentRequest.apiType) {
case "blocks":
const { apiType: _, ...blockRequest } = requestWithPagination;
response = await api.getBuilderBlocks(blockRequest);
newData = response.blocks as GetReturnType<T>[];
pagination = response.pagination;
break;
case "providers":
const { apiType: __, ...providerRequest } = requestWithPagination;
response = await api.getProviders(providerRequest);
newData = response.providers as GetReturnType<T>[];
pagination = response.pagination;
break;
case "store-agents":
const { apiType: ___, ...storeAgentRequest } =
requestWithPagination;
response = await api.getStoreAgents(storeAgentRequest);
newData = response.agents as GetReturnType<T>[];
pagination = response.pagination;
break;
case "library-agents":
const { apiType: ____, ...libraryAgentRequest } =
requestWithPagination;
response = await api.listLibraryAgents(libraryAgentRequest);
newData = response.agents as GetReturnType<T>[];
pagination = response.pagination;
break;
default:
throw new Error(
`Unknown request type: ${(currentRequest as any).apiType}`,
);
}
if (isLoadMore) {
setData((prev) => [...prev, ...newData]);
} else {
setData(newData);
}
setHasMore(page < pagination.total_pages);
setCurrentPage(page);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to fetch data";
setError(errorMessage);
console.error("Error fetching data:", err);
} finally {
setLoading(false);
setLoadingMore(false);
isLoadingRef.current = false;
}
},
[api, pageSize, enabled],
);
const handleScroll = useCallback(() => {
const scrollElement = scrollRef.current;
if (
!scrollElement ||
loadingMore ||
!hasMore ||
isLoadingRef.current ||
!enabled
)
return;
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const threshold = 100;
if (scrollTop + clientHeight >= scrollHeight - threshold) {
fetchData(currentPage + 1, true);
}
}, [fetchData, currentPage, loadingMore, hasMore, enabled]);
const refresh = useCallback(() => {
setCurrentPage(1);
setHasMore(true);
setError(null);
fetchData(1);
}, [fetchData]);
const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !isLoadingRef.current && enabled) {
fetchData(currentPage + 1, true);
}
}, [fetchData, currentPage, loadingMore, hasMore, enabled]);
const requestString = JSON.stringify(request);
useEffect(() => {
if (enabled) {
setCurrentPage(1);
setHasMore(true);
setError(null);
setData([]);
fetchData(1);
}
}, [requestString, enabled, fetchData]);
useEffect(() => {
const scrollElement = scrollRef.current;
if (scrollElement && enabled) {
scrollElement.addEventListener("scroll", handleScroll);
return () => scrollElement.removeEventListener("scroll", handleScroll);
}
}, [handleScroll, enabled]);
return {
data,
loading,
loadingMore,
hasMore,
error,
scrollRef,
refresh,
loadMore,
};
};

View File

@@ -9,6 +9,11 @@ import type {
APIKeyCredentials,
APIKeyPermission,
Block,
BlockCategoryResponse,
BlockRequest,
BlockResponse,
BlockSearchResponse,
CountResponse,
CreateAPIKeyResponse,
CreatorDetails,
CreatorsResponse,
@@ -42,6 +47,7 @@ import type {
OttoQuery,
OttoResponse,
ProfileDetails,
ProviderResponse,
RefundRequest,
ReviewSubmissionRequest,
Schedule,
@@ -56,6 +62,7 @@ import type {
StoreSubmissionRequest,
StoreSubmissionsResponse,
SubmissionStatus,
SuggestionsResponse,
TransactionHistory,
User,
UserOnboarding,
@@ -206,6 +213,44 @@ export default class BackendAPI {
return this._get("/onboarding/enabled");
}
////////////////////////////////////////
//////////////// BUILDER ///////////////
////////////////////////////////////////
getSuggestions(): Promise<SuggestionsResponse> {
return this._get("/builder/suggestions");
}
getBlockCategories(): Promise<BlockCategoryResponse[]> {
return this._get("/builder/categories");
}
getBuilderBlocks(request?: BlockRequest): Promise<BlockResponse> {
return this._get("/builder/blocks", request);
}
getProviders(request?: {
page?: number;
page_size?: number;
}): Promise<ProviderResponse> {
return this._get("/builder/providers", request);
}
searchBlocks(options: {
search_query?: string;
filter?: ("blocks" | "integrations" | "marketplace_agents" | "my_agents")[];
by_creator?: string[];
search_id?: string;
page?: number;
page_size?: number;
}): Promise<BlockSearchResponse> {
return this._request("POST", "/builder/search", options);
}
getBlockCounts(): Promise<CountResponse> {
return this._get("/builder/counts");
}
////////////////////////////////////////
//////////////// GRAPHS ////////////////
////////////////////////////////////////

View File

@@ -27,6 +27,71 @@ export type BlockCost = {
cost_filter: { [key: string]: any };
};
/* Mirror of backend/server/v2/builder/model.py:SuggestionsResponse */
export type SuggestionsResponse = {
otto_suggestions: string[];
recent_searches: string[];
providers: string[];
top_blocks: Block[];
};
/* Mirror of backend/server/v2/builder/model.py:BlockCategoryResponse */
export type BlockCategoryResponse = {
name: string;
total_blocks: number;
blocks: Block[];
};
export type BlockRequest = {
page?: number;
page_size?: number;
} & (
| { category?: string }
| { type?: "all" | "input" | "action" | "output" }
| { provider?: CredentialsProviderName }
);
/* Mirror of backend/server/v2/builder/model.py:BlockReponse */
export type BlockResponse = {
blocks: Block[];
pagination: Pagination;
};
/* Mirror of backend/server/v2/builder/model.py:Provider */
export type Provider = {
name: CredentialsProviderName;
description: string;
integration_count: number;
};
/* Mirror of backend/server/v2/builder/model.py:ProviderResponse */
export type ProviderResponse = {
providers: Provider[];
pagination: Pagination;
};
/* Mirror of backend/server/v2/builder/model.py:BlockSearchResponse */
export type BlockSearchResponse = {
items: (Block | LibraryAgent | StoreAgent)[];
total_items: Record<
"blocks" | "integrations" | "marketplace_agents" | "my_agents",
number
>;
page: number;
more_pages: boolean;
};
/* Mirror of backend/server/v2/builder/model.py:CountResponse */
export type CountResponse = {
all_blocks: number;
input_blocks: number;
action_blocks: number;
output_blocks: number;
integrations: number;
marketplace_agents: number;
my_agents: number;
};
/* Mirror of backend/data/block.py:Block */
export type Block = {
id: string;
@@ -402,6 +467,7 @@ export type LibraryAgent = {
name: string;
description: string;
input_schema: BlockIOObjectSubSchema;
output_schema: BlockIOObjectSubSchema;
new_output: boolean;
can_access_graph: boolean;
is_latest_version: boolean;

Some files were not shown because too many files have changed in this diff Show More