mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
refactor: block control performance
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
@@ -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(),
|
||||
]),
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user