refactor: block control performance

This commit is contained in:
Lluis Agusti
2025-07-09 20:36:55 +04:00
parent fe36ba55dd
commit eecae2da3c
4 changed files with 295 additions and 166 deletions

View File

@@ -43,7 +43,7 @@ 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 { BlocksControl } from "@/components/edit/control/BlocksControl/BlocksControl";
import { IconUndo2, IconRedo2 } from "@/components/ui/icons";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { startTutorial } from "./tutorial";

View File

@@ -1,30 +1,28 @@
import React, { useState, useMemo } from "react";
import React from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TextRenderer } from "@/components/ui/render";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CustomNode } from "@/components/CustomNode";
import { beautifyString } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Block, BlockUIType, SpecialBlockID } from "@/lib/autogpt-server-api";
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
import { IconToyBrick } from "@/components/ui/icons";
import { getPrimaryCategoryColor } from "@/lib/utils";
import { getPrimaryCategoryColor, beautifyString } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { GraphMeta } from "@/lib/autogpt-server-api";
import jaro from "jaro-winkler";
import { Block, GraphMeta } from "@/lib/autogpt-server-api";
import { CustomNode } from "@/components/CustomNode";
import { useBlocksControl } from "./useBlocksControl";
interface BlocksControlProps {
interface Props {
blocks: Block[];
addBlock: (
id: string,
@@ -38,161 +36,30 @@ interface BlocksControlProps {
/**
* A React functional component that displays a control for managing blocks.
*
* @component
* @param {Object} BlocksControlProps - The properties for the BlocksControl component.
* @param {Block[]} BlocksControlProps.blocks - An array of blocks to be displayed and filtered.
* @param {(id: string, name: string) => void} BlocksControlProps.addBlock - A function to call when a block is added.
* @returns The rendered BlocksControl component.
* Optimized for performance with debounced search, memoized data, and separated concerns.
*/
export const BlocksControl: React.FC<BlocksControlProps> = ({
function BlocksControlComponent({
blocks,
addBlock,
pinBlocksPopover,
flows,
nodes,
}) => {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const graphHasWebhookNodes = nodes.some((n) =>
[BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(n.data.uiType),
);
const graphHasInputNodes = nodes.some(
(n) => n.data.uiType == BlockUIType.INPUT,
);
const filteredAvailableBlocks = useMemo(() => {
const blockList = blocks
.filter((b) => b.uiType !== BlockUIType.AGENT)
.sort((a, b) => a.name.localeCompare(b.name));
const agentBlockList = flows.map(
(flow) =>
({
id: SpecialBlockID.AGENT,
name: flow.name,
description:
`Ver.${flow.version}` +
(flow.description ? ` | ${flow.description}` : ""),
categories: [{ category: "AGENT", description: "" }],
inputSchema: flow.input_schema,
outputSchema: flow.output_schema,
staticOutput: false,
uiType: BlockUIType.AGENT,
uiKey: flow.id,
costs: [],
hardcodedValues: {
graph_id: flow.id,
graph_version: flow.version,
input_schema: flow.input_schema,
output_schema: flow.output_schema,
},
}) satisfies Block,
);
/**
* Evaluates how well a block matches the search query and returns a relevance score.
* The scoring algorithm works as follows:
* - Returns 1 if no query (all blocks match equally)
* - Normalized query for case-insensitive matching
* - Returns 3 for exact substring matches in block name (highest priority)
* - Returns 2 when all query words appear in the block name (regardless of order)
* - Returns 1.X for blocks with names similar to query using Jaro-Winkler distance (X is similarity score)
* - Returns 0.5 when all query words appear in the block description (lowest priority)
* - Returns 0 for no match
*
* Higher scores will appear first in search results.
*/
const matchesSearch = (block: Block, query: string): number => {
if (!query) return 1;
const normalizedQuery = query.toLowerCase().trim();
const queryWords = normalizedQuery.split(/\s+/);
const blockName = block.name.toLowerCase();
const beautifiedName = beautifyString(block.name).toLowerCase();
const description = block.description.toLowerCase();
// 1. Exact match in name (highest priority)
if (
blockName.includes(normalizedQuery) ||
beautifiedName.includes(normalizedQuery)
) {
return 3;
}
// 2. All query words in name (regardless of order)
const allWordsInName = queryWords.every(
(word) => blockName.includes(word) || beautifiedName.includes(word),
);
if (allWordsInName) return 2;
// 3. Similarity with name (Jaro-Winkler)
const similarityThreshold = 0.65;
const nameSimilarity = jaro(blockName, normalizedQuery);
const beautifiedSimilarity = jaro(beautifiedName, normalizedQuery);
const maxSimilarity = Math.max(nameSimilarity, beautifiedSimilarity);
if (maxSimilarity > similarityThreshold) {
return 1 + maxSimilarity; // Score between 1 and 2
}
// 4. All query words in description (lower priority)
const allWordsInDescription = queryWords.every((word) =>
description.includes(word),
);
if (allWordsInDescription) return 0.5;
return 0;
};
return blockList
.concat(agentBlockList)
.map((block) => ({
block,
score: matchesSearch(block, searchQuery),
}))
.filter(
({ block, score }) =>
score > 0 &&
(!selectedCategory ||
block.categories.some((cat) => cat.category === selectedCategory)),
)
.sort((a, b) => b.score - a.score)
.map(({ block }) => ({
...block,
notAvailable:
(block.uiType == BlockUIType.WEBHOOK &&
graphHasWebhookNodes &&
"Agents can only have one webhook-triggered block") ||
(block.uiType == BlockUIType.WEBHOOK &&
graphHasInputNodes &&
"Webhook-triggered blocks can't be used together with input blocks") ||
(block.uiType == BlockUIType.INPUT &&
graphHasWebhookNodes &&
"Input blocks can't be used together with a webhook-triggered block") ||
null,
}));
}, [
}: Props) {
const {
searchQuery,
setSearchQuery,
selectedCategory,
filteredAvailableBlocks,
categories,
resetFilters,
handleCategoryClick,
handleAddBlock,
} = useBlocksControl({
blocks,
flows,
searchQuery,
selectedCategory,
graphHasInputNodes,
graphHasWebhookNodes,
]);
const resetFilters = React.useCallback(() => {
setSearchQuery("");
setSelectedCategory(null);
}, []);
// Extract unique categories from blocks
const categories = Array.from(
new Set([
null,
...blocks
.flatMap((block) => block.categories.map((cat) => cat.category))
.sort(),
]),
);
nodes,
addBlock,
});
return (
<Popover
@@ -258,11 +125,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
<div
key={category}
className={`cursor-pointer rounded-xl border px-2 py-2 text-xs font-medium dark:border-slate-700 dark:text-white ${colorClass}`}
onClick={() =>
setSelectedCategory(
selectedCategory === category ? null : category,
)
}
onClick={() => handleCategoryClick(category)}
>
{beautifyString((category || "All").toLowerCase())}
</div>
@@ -284,10 +147,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
: "cursor-pointer hover:shadow-lg"
}`}
data-id={`block-card-${block.id}`}
onClick={() =>
!block.notAvailable &&
addBlock(block.id, block.name, block?.hardcodedValues || {})
}
onClick={() => handleAddBlock(block)}
title={block.notAvailable ?? undefined}
>
<div
@@ -336,4 +196,8 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
</PopoverContent>
</Popover>
);
};
}
// Set display name and export memoized component
BlocksControlComponent.displayName = "BlocksControl";
export const BlocksControl = React.memo(BlocksControlComponent);

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
import { beautifyString } from "@/lib/utils";
import { Block, BlockUIType } from "@/lib/autogpt-server-api";
import jaro from "jaro-winkler";
// Types for performance optimization
export interface BlockSearchData {
blockName: string;
beautifiedName: string;
description: string;
}
export interface EnhancedBlock extends Block {
searchData: BlockSearchData;
}
export interface BlockWithAvailability extends Block {
notAvailable?: string | null;
}
export interface GraphState {
hasWebhookNodes: boolean;
hasInputNodes: boolean;
}
// Custom hook for debouncing search input
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
// Memoized function to precompute search data for blocks
export const getBlockSearchData = (
block: Pick<Block, "name" | "description">,
): BlockSearchData => ({
blockName: block.name.toLowerCase(),
beautifiedName: beautifyString(block.name).toLowerCase(),
description: block.description.toLowerCase(),
});
// Optimized search matching function
export const matchesSearch = (block: EnhancedBlock, query: string): number => {
if (!query) return 1;
const normalizedQuery = query.toLowerCase().trim();
const queryWords = normalizedQuery.split(/\s+/);
const { blockName, beautifiedName, description } = block.searchData;
// 1. Exact match in name (highest priority)
if (
blockName.includes(normalizedQuery) ||
beautifiedName.includes(normalizedQuery)
) {
return 3;
}
// 2. All query words in name (regardless of order)
const allWordsInName = queryWords.every(
(word) => blockName.includes(word) || beautifiedName.includes(word),
);
if (allWordsInName) return 2;
// 3. Similarity with name (Jaro-Winkler) - Only for short queries to avoid performance issues
if (normalizedQuery.length <= 12) {
const similarityThreshold = 0.65;
const nameSimilarity = jaro(blockName, normalizedQuery);
const beautifiedSimilarity = jaro(beautifiedName, normalizedQuery);
const maxSimilarity = Math.max(nameSimilarity, beautifiedSimilarity);
if (maxSimilarity > similarityThreshold) {
return 1 + maxSimilarity; // Score between 1 and 2
}
}
// 4. All query words in description (lower priority)
const allWordsInDescription = queryWords.every((word) =>
description.includes(word),
);
if (allWordsInDescription) return 0.5;
return 0;
};
// Helper to check block availability based on graph state
export const getBlockAvailability = (
block: Block,
graphState: GraphState,
): string | null => {
if (block.uiType === BlockUIType.WEBHOOK && graphState.hasWebhookNodes) {
return "Agents can only have one webhook-triggered block";
}
if (block.uiType === BlockUIType.WEBHOOK && graphState.hasInputNodes) {
return "Webhook-triggered blocks can't be used together with input blocks";
}
if (block.uiType === BlockUIType.INPUT && graphState.hasWebhookNodes) {
return "Input blocks can't be used together with a webhook-triggered block";
}
return null;
};
// Helper to extract unique categories from blocks
export const extractCategories = (blocks: Block[]): (string | null)[] => {
return Array.from(
new Set([
null,
...blocks
.flatMap((block) => block.categories.map((cat) => cat.category))
.sort(),
]),
);
};

View File

@@ -0,0 +1,146 @@
import { useState, useMemo } from "react";
import {
Block,
BlockUIType,
SpecialBlockID,
GraphMeta,
} from "@/lib/autogpt-server-api";
import { CustomNode } from "@/components/CustomNode";
import {
useDebounce,
getBlockSearchData,
matchesSearch,
getBlockAvailability,
extractCategories,
EnhancedBlock,
BlockWithAvailability,
GraphState,
} from "./helpers";
interface Args {
blocks: Block[];
flows: GraphMeta[];
nodes: CustomNode[];
addBlock: (
id: string,
name: string,
hardcodedValues: Record<string, any>,
) => void;
}
export function useBlocksControl({ blocks, flows, nodes, addBlock }: Args) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
// Debounce search query to reduce expensive operations
const debouncedSearchQuery = useDebounce(searchQuery, 200);
// Memoize graph state checks to avoid recalculating on every render
const graphState = useMemo(
(): GraphState => ({
hasWebhookNodes: nodes.some((n) =>
[BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(
n.data.uiType,
),
),
hasInputNodes: nodes.some((n) => n.data.uiType === BlockUIType.INPUT),
}),
[nodes],
);
// Memoize blocks with precomputed search data
const blocksWithSearchData = useMemo((): EnhancedBlock[] => {
return blocks.map((block) => ({
...block,
searchData: getBlockSearchData(block),
}));
}, [blocks]);
// Memoize agent blocks list with search data
const agentBlocksWithSearchData = useMemo((): EnhancedBlock[] => {
return flows.map((flow) => {
const description = `Ver.${flow.version}${flow.description ? ` | ${flow.description}` : ""}`;
return {
id: SpecialBlockID.AGENT,
name: flow.name,
description,
categories: [{ category: "AGENT", description: "" }],
inputSchema: flow.input_schema,
outputSchema: flow.output_schema,
staticOutput: false,
uiType: BlockUIType.AGENT,
uiKey: flow.id,
costs: [],
hardcodedValues: {
graph_id: flow.id,
graph_version: flow.version,
input_schema: flow.input_schema,
output_schema: flow.output_schema,
},
searchData: getBlockSearchData({ name: flow.name, description }),
} satisfies EnhancedBlock;
});
}, [flows]);
// Memoize filtered and sorted blocks
const filteredAvailableBlocks = useMemo((): BlockWithAvailability[] => {
const blockList = blocksWithSearchData
.filter((b) => b.uiType !== BlockUIType.AGENT)
.sort((a, b) => a.name.localeCompare(b.name));
const allBlocks = blockList.concat(agentBlocksWithSearchData);
return allBlocks
.map((block) => ({
block,
score: matchesSearch(block, debouncedSearchQuery),
}))
.filter(
({ block, score }) =>
score > 0 &&
(!selectedCategory ||
block.categories.some((cat) => cat.category === selectedCategory)),
)
.sort((a, b) => b.score - a.score)
.map(({ block }) => ({
...block,
notAvailable: getBlockAvailability(block, graphState),
}));
}, [
blocksWithSearchData,
agentBlocksWithSearchData,
debouncedSearchQuery,
selectedCategory,
graphState,
]);
// Memoize unique categories extraction
const categories = useMemo(() => extractCategories(blocks), [blocks]);
// Event handlers
function resetFilters() {
setSearchQuery("");
setSelectedCategory(null);
}
function handleCategoryClick(category: string | null) {
setSelectedCategory(selectedCategory === category ? null : category);
}
function handleAddBlock(block: BlockWithAvailability) {
if (!block.notAvailable) {
addBlock(block.id, block.name, block?.hardcodedValues || {});
}
}
return {
searchQuery,
setSearchQuery,
selectedCategory,
filteredAvailableBlocks,
categories,
resetFilters,
handleCategoryClick,
handleAddBlock,
};
}