mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): Add graph search functionality to builder (#10776)
## Summary - Added search functionality to find nodes in the graph by block type, node ID, and input/output names - Search icon added to both new and old control panels - Implemented node highlighting on hover and navigation on click https://github.com/user-attachments/assets/8cc69186-5582-446d-b2cd-601de992144f ## Changes - Created `GraphSearchMenu` component for the new control panel - Created `GraphSearchControl` component for the old control panel - Added `GraphSearchContent` component with search UI similar to BlockMenu - Implemented `useGraphSearch` hook with fuzzy search logic - Added node highlighting without viewport movement on hover - Added node navigation with centering and highlighting on selection ## Features - Search by block type name, node ID, or input/output field names - Real-time filtering with keyboard navigation support - Visual feedback with node highlighting on hover - Click to navigate and center on selected node - Consistent styling with BlockMenu including category colors - Works in both old and new control panels ## Test plan - [x] Test search functionality in both old and new control panels - [x] Verify search by block type name works - [x] Verify search by node ID works - [x] Verify search by input/output names works - [x] Test keyboard navigation (arrow keys and enter) - [x] Verify node highlighting on hover - [x] Verify node navigation on click - [x] Check popover alignment with control panel top
This commit is contained in:
@@ -61,24 +61,27 @@ poetry run pytest path/to/test.py --snapshot-update
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
cd frontend && npm install
|
||||
cd frontend && pnpm i
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
pnpm dev
|
||||
|
||||
# Run E2E tests
|
||||
npm run test
|
||||
pnpm test
|
||||
|
||||
# Run Storybook for component development
|
||||
npm run storybook
|
||||
pnpm storybook
|
||||
|
||||
# Build production
|
||||
npm run build
|
||||
pnpm build
|
||||
|
||||
# Type checking
|
||||
npm run types
|
||||
pnpm types
|
||||
```
|
||||
|
||||
We have a components library in autogpt_platform/frontend/src/components/atoms that should be used when adding new pages and components.
|
||||
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Backend Architecture
|
||||
|
||||
@@ -12,9 +12,9 @@ import { BlockMenuStateProvider } from "../block-menu-provider";
|
||||
|
||||
interface BlockMenuProps {
|
||||
pinBlocksPopover: boolean;
|
||||
blockMenuSelected: "save" | "block" | "";
|
||||
blockMenuSelected: "save" | "block" | "search" | "";
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "block">
|
||||
React.SetStateAction<"" | "save" | "block" | "search">
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
interface useBlockMenuProps {
|
||||
pinBlocksPopover: boolean;
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "block">
|
||||
React.SetStateAction<"" | "save" | "block" | "search">
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import { GraphSearchContent } from "../GraphMenuContent/GraphContent";
|
||||
import { ControlPanelButton } from "../ControlPanelButton";
|
||||
import { CustomNode } from "@/components/CustomNode";
|
||||
import { useGraphMenu } from "./useGraphMenu";
|
||||
|
||||
interface GraphSearchMenuProps {
|
||||
nodes: CustomNode[];
|
||||
blockMenuSelected: "save" | "block" | "search" | "";
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "block" | "search">
|
||||
>;
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string | null) => void;
|
||||
}
|
||||
|
||||
export const GraphSearchMenu: React.FC<GraphSearchMenuProps> = ({
|
||||
nodes,
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
onNodeSelect,
|
||||
onNodeHover,
|
||||
}) => {
|
||||
const {
|
||||
open,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredNodes,
|
||||
handleNodeSelect,
|
||||
handleOpenChange,
|
||||
} = useGraphMenu({
|
||||
nodes,
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
onNodeSelect,
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger className="hover:cursor-pointer">
|
||||
<ControlPanelButton
|
||||
data-id="graph-search-control-popover-trigger"
|
||||
data-testid="graph-search-control-button"
|
||||
selected={blockMenuSelected === "search"}
|
||||
className="rounded-none"
|
||||
>
|
||||
<MagnifyingGlassIcon 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="graph-search-popover-content"
|
||||
>
|
||||
<GraphSearchContent
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filteredNodes={filteredNodes}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNodeHover={onNodeHover}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useGraphSearch } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
|
||||
import { CustomNode } from "@/components/CustomNode";
|
||||
|
||||
interface UseGraphMenuProps {
|
||||
nodes: CustomNode[];
|
||||
blockMenuSelected: "save" | "block" | "search" | "";
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "block" | "search">
|
||||
>;
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export const useGraphMenu = ({
|
||||
nodes,
|
||||
setBlockMenuSelected,
|
||||
onNodeSelect,
|
||||
}: UseGraphMenuProps) => {
|
||||
const { open, setOpen, searchQuery, setSearchQuery, filteredNodes } =
|
||||
useGraphSearch(nodes);
|
||||
|
||||
const handleNodeSelect = (nodeId: string) => {
|
||||
onNodeSelect(nodeId);
|
||||
setOpen(false);
|
||||
setSearchQuery("");
|
||||
setBlockMenuSelected("");
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
setBlockMenuSelected(newOpen ? "search" : "");
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredNodes,
|
||||
handleNodeSelect,
|
||||
handleOpenChange,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { beautifyString, getPrimaryCategoryColor } from "@/lib/utils";
|
||||
import { SearchableNode } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
|
||||
import { TextRenderer } from "@/components/ui/render";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { GraphMenuSearchBar } from "../GraphMenuSearchBar/GraphMenuSearchBar";
|
||||
import { useGraphContent } from "./useGraphContent";
|
||||
|
||||
interface GraphSearchContentProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
filteredNodes: SearchableNode[];
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string | null) => void;
|
||||
}
|
||||
|
||||
export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
filteredNodes,
|
||||
onNodeSelect,
|
||||
onNodeHover,
|
||||
}) => {
|
||||
const {
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
handleKeyDown,
|
||||
getNodeInputOutputSummary,
|
||||
} = useGraphContent({
|
||||
searchQuery,
|
||||
filteredNodes,
|
||||
onNodeSelect,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* Search Bar */}
|
||||
<GraphMenuSearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
<Separator className="h-[1px] w-full text-zinc-300" />
|
||||
|
||||
{/* Search Results */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{searchQuery && (
|
||||
<div className="px-4 py-2 text-xs text-gray-500">
|
||||
Found {filteredNodes.length} node{filteredNodes.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-full w-full">
|
||||
{filteredNodes.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{searchQuery ? "No nodes found matching your search" : "Start typing to search nodes"}
|
||||
</div>
|
||||
) : (
|
||||
filteredNodes.map((node, index) => {
|
||||
// Safety check for node data
|
||||
if (!node || !node.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeTitle = node.data?.metadata?.customized_name ||
|
||||
beautifyString(node.data?.blockType || "").replace(/ Block$/, "");
|
||||
const nodeType = beautifyString(node.data?.blockType || "").replace(/ Block$/, "");
|
||||
|
||||
return (
|
||||
<TooltipProvider key={node.id}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`mx-4 my-2 flex h-20 cursor-pointer rounded-lg border border-zinc-200 bg-white ${
|
||||
index === selectedIndex
|
||||
? "border-zinc-400 shadow-md"
|
||||
: "hover:border-zinc-300 hover:shadow-sm"
|
||||
}`}
|
||||
onClick={() => onNodeSelect(node.id)}
|
||||
onMouseEnter={() => {
|
||||
setSelectedIndex(index);
|
||||
onNodeHover?.(node.id);
|
||||
}}
|
||||
onMouseLeave={() => onNodeHover?.(null)}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-3 rounded-l-[7px] ${getPrimaryCategoryColor(node.data?.categories)}`}
|
||||
/>
|
||||
<div className="mx-3 flex flex-1 items-center justify-between">
|
||||
<div className="mr-2 min-w-0">
|
||||
<span className="block truncate pb-1 text-sm font-semibold text-zinc-800">
|
||||
<TextRenderer
|
||||
value={nodeTitle}
|
||||
truncateLengthLimit={45}
|
||||
/>
|
||||
</span>
|
||||
<span className="block break-all text-xs font-normal text-zinc-500">
|
||||
<TextRenderer
|
||||
value={getNodeInputOutputSummary(node) || node.data?.description || ""}
|
||||
truncateLengthLimit={165}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold">Node Type: {nodeType}</div>
|
||||
{node.data?.metadata?.customized_name && (
|
||||
<div className="text-xs text-gray-500">Custom Name: {node.data.metadata.customized_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { SearchableNode } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
|
||||
|
||||
interface UseGraphContentProps {
|
||||
searchQuery: string;
|
||||
filteredNodes: SearchableNode[];
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export const useGraphContent = ({
|
||||
searchQuery,
|
||||
filteredNodes,
|
||||
onNodeSelect,
|
||||
}: UseGraphContentProps) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filteredNodes.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === "Enter" && filteredNodes.length > 0) {
|
||||
e.preventDefault();
|
||||
onNodeSelect(filteredNodes[selectedIndex].id);
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeInputOutputSummary = (node: SearchableNode) => {
|
||||
// Safety check for node data
|
||||
if (!node || !node.data) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const inputs = Object.keys(node.data?.inputSchema?.properties || {});
|
||||
const outputs = Object.keys(node.data?.outputSchema?.properties || {});
|
||||
const parts = [];
|
||||
|
||||
if (inputs.length > 0) {
|
||||
parts.push(`Inputs: ${inputs.slice(0, 3).join(", ")}${inputs.length > 3 ? "..." : ""}`);
|
||||
}
|
||||
if (outputs.length > 0) {
|
||||
parts.push(`Outputs: ${outputs.slice(0, 3).join(", ")}${outputs.length > 3 ? "..." : ""}`);
|
||||
}
|
||||
|
||||
return parts.join(" | ");
|
||||
};
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
handleKeyDown,
|
||||
getNodeInputOutputSummary,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MagnifyingGlassIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { useGraphMenuSearchBarComponent } from "./useGraphMenuSearchBarComponent";
|
||||
|
||||
interface GraphMenuSearchBarProps {
|
||||
className?: string;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export const GraphMenuSearchBar: React.FC<GraphMenuSearchBarProps> = ({
|
||||
className = "",
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const { inputRef, handleClear } = useGraphMenuSearchBarComponent({
|
||||
onSearchChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[3.5625rem] items-center gap-2.5 px-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
<MagnifyingGlassIcon className="h-6 w-6 text-zinc-700" strokeWidth={2} />
|
||||
</div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={"Search your graph for nodes, inputs, outputs..."}
|
||||
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",
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={"sm"}
|
||||
onClick={handleClear}
|
||||
className="p-0 hover:bg-transparent"
|
||||
>
|
||||
<XIcon className="h-6 w-6 text-zinc-700" strokeWidth={2} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useMemo, useDeferredValue } from "react";
|
||||
import { CustomNode } from "@/components/CustomNode";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import jaro from "jaro-winkler";
|
||||
|
||||
export type SearchableNode = CustomNode & {
|
||||
searchScore?: number;
|
||||
matchedFields?: string[];
|
||||
};
|
||||
|
||||
export const useGraphSearch = (nodes: CustomNode[]) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
// Filter out invalid nodes
|
||||
const validNodes = (nodes || []).filter(node => node && node.data);
|
||||
|
||||
if (!deferredSearchQuery.trim()) {
|
||||
return validNodes.map(node => ({ ...node, searchScore: 1, matchedFields: [] }));
|
||||
}
|
||||
|
||||
const query = deferredSearchQuery.toLowerCase().trim();
|
||||
const queryWords = query.split(/\s+/);
|
||||
|
||||
return validNodes
|
||||
.map((node): SearchableNode => {
|
||||
const { score, matchedFields } = calculateNodeScore(node, query, queryWords);
|
||||
return { ...node, searchScore: score, matchedFields };
|
||||
})
|
||||
.filter(node => node.searchScore! > 0)
|
||||
.sort((a, b) => b.searchScore! - a.searchScore!);
|
||||
}, [nodes, deferredSearchQuery]);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredNodes,
|
||||
};
|
||||
};
|
||||
|
||||
function calculateNodeScore(
|
||||
node: CustomNode,
|
||||
query: string,
|
||||
queryWords: string[]
|
||||
): { score: number; matchedFields: string[] } {
|
||||
const matchedFields: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// Safety check for node data
|
||||
if (!node || !node.data) {
|
||||
return { score: 0, matchedFields: [] };
|
||||
}
|
||||
|
||||
// Prepare searchable text with defensive checks
|
||||
const nodeTitle = (node.data?.title || "").toLowerCase(); // This includes the ID
|
||||
const nodeId = (node.id || "").toLowerCase();
|
||||
const nodeDescription = (node.data?.description || "").toLowerCase();
|
||||
const blockType = (node.data?.blockType || "").toLowerCase();
|
||||
const beautifiedBlockType = beautifyString(blockType).toLowerCase();
|
||||
const customizedName = (node.data?.metadata?.customized_name || "").toLowerCase();
|
||||
|
||||
// Get input and output names with defensive checks
|
||||
const inputNames = Object.keys(node.data?.inputSchema?.properties || {})
|
||||
.map(key => key.toLowerCase());
|
||||
const outputNames = Object.keys(node.data?.outputSchema?.properties || {})
|
||||
.map(key => key.toLowerCase());
|
||||
|
||||
// 1. Check exact match in customized name, title (includes ID), node ID, or block type (highest priority)
|
||||
if (customizedName.includes(query) || nodeTitle.includes(query) || nodeId.includes(query) || blockType.includes(query) || beautifiedBlockType.includes(query)) {
|
||||
score = 4;
|
||||
matchedFields.push("title");
|
||||
}
|
||||
|
||||
// 2. Check all query words in customized name, title or block type
|
||||
else if (queryWords.every(word => customizedName.includes(word) || nodeTitle.includes(word) || beautifiedBlockType.includes(word))) {
|
||||
score = 3.5;
|
||||
matchedFields.push("title");
|
||||
}
|
||||
|
||||
// 3. Check exact match in input/output names
|
||||
else if (inputNames.some(name => name.includes(query))) {
|
||||
score = 3;
|
||||
matchedFields.push("inputs");
|
||||
}
|
||||
else if (outputNames.some(name => name.includes(query))) {
|
||||
score = 2.8;
|
||||
matchedFields.push("outputs");
|
||||
}
|
||||
|
||||
// 4. Check all query words in input/output names
|
||||
else if (inputNames.some(name => queryWords.every(word => name.includes(word)))) {
|
||||
score = 2.5;
|
||||
matchedFields.push("inputs");
|
||||
}
|
||||
else if (outputNames.some(name => queryWords.every(word => name.includes(word)))) {
|
||||
score = 2.3;
|
||||
matchedFields.push("outputs");
|
||||
}
|
||||
|
||||
// 5. Similarity matching using Jaro-Winkler
|
||||
else {
|
||||
const titleSimilarity = Math.max(
|
||||
jaro(customizedName, query),
|
||||
jaro(nodeTitle, query),
|
||||
jaro(nodeId, query),
|
||||
jaro(beautifiedBlockType, query)
|
||||
);
|
||||
|
||||
if (titleSimilarity > 0.7) {
|
||||
score = 1.5 + titleSimilarity;
|
||||
matchedFields.push("title");
|
||||
}
|
||||
|
||||
// Check similarity with input/output names
|
||||
const inputSimilarity = Math.max(...inputNames.map(name => jaro(name, query)), 0);
|
||||
const outputSimilarity = Math.max(...outputNames.map(name => jaro(name, query)), 0);
|
||||
|
||||
if (inputSimilarity > 0.7 && inputSimilarity > score) {
|
||||
score = 1 + inputSimilarity;
|
||||
matchedFields.push("inputs");
|
||||
}
|
||||
if (outputSimilarity > 0.7 && outputSimilarity > score) {
|
||||
score = 0.8 + outputSimilarity;
|
||||
matchedFields.push("outputs");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check description (lower priority)
|
||||
if (score === 0 && nodeDescription.includes(query)) {
|
||||
score = 0.5;
|
||||
matchedFields.push("description");
|
||||
}
|
||||
|
||||
// 7. Check if all query words appear in description
|
||||
if (score === 0 && queryWords.every(word => nodeDescription.includes(word))) {
|
||||
score = 0.3;
|
||||
matchedFields.push("description");
|
||||
}
|
||||
|
||||
return { score, matchedFields };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
interface UseGraphMenuSearchBarComponentProps {
|
||||
onSearchChange: (query: string) => void;
|
||||
}
|
||||
|
||||
export const useGraphMenuSearchBarComponent = ({
|
||||
onSearchChange,
|
||||
}: UseGraphMenuSearchBarComponentProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClear = () => {
|
||||
onSearchChange("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return {
|
||||
inputRef,
|
||||
handleClear,
|
||||
};
|
||||
};
|
||||
@@ -8,6 +8,9 @@ import { GraphExecutionID } from "@/lib/autogpt-server-api";
|
||||
import { history } from "@/components/history";
|
||||
import { ControlPanelButton } from "../ControlPanelButton";
|
||||
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
|
||||
import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
|
||||
import { CustomNode } from "@/components/CustomNode";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
export type Control = {
|
||||
icon: React.ReactNode;
|
||||
@@ -22,6 +25,9 @@ interface ControlPanelProps {
|
||||
visualizeBeads: "no" | "static" | "animate";
|
||||
pinSavePopover: boolean;
|
||||
pinBlocksPopover: boolean;
|
||||
nodes: CustomNode[];
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string | null) => void;
|
||||
}
|
||||
|
||||
export const NewControlPanel = ({
|
||||
@@ -29,8 +35,13 @@ export const NewControlPanel = ({
|
||||
visualizeBeads,
|
||||
pinSavePopover,
|
||||
pinBlocksPopover,
|
||||
nodes,
|
||||
onNodeSelect,
|
||||
onNodeHover,
|
||||
className,
|
||||
}: ControlPanelProps) => {
|
||||
const isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
|
||||
|
||||
const {
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
@@ -77,6 +88,18 @@ export const NewControlPanel = ({
|
||||
setBlockMenuSelected={setBlockMenuSelected}
|
||||
/>
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
{isGraphSearchEnabled && (
|
||||
<>
|
||||
<GraphSearchMenu
|
||||
nodes={nodes}
|
||||
blockMenuSelected={blockMenuSelected}
|
||||
setBlockMenuSelected={setBlockMenuSelected}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onNodeHover={onNodeHover}
|
||||
/>
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
</>
|
||||
)}
|
||||
{controls.map((control, index) => (
|
||||
<ControlPanelButton
|
||||
key={index}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface NewControlPanelProps {
|
||||
|
||||
export const useNewControlPanel = ({flowExecutionID, visualizeBeads}: NewControlPanelProps) => {
|
||||
const [blockMenuSelected, setBlockMenuSelected] = useState<
|
||||
"save" | "block" | ""
|
||||
"save" | "block" | "search" | ""
|
||||
>("");
|
||||
const query = useSearchParams();
|
||||
const _graphVersion = query.get("flowVersion");
|
||||
|
||||
@@ -23,9 +23,9 @@ interface SaveControlProps {
|
||||
onDescriptionChange: (description: string) => void;
|
||||
pinSavePopover: boolean;
|
||||
|
||||
blockMenuSelected: "save" | "block" | "";
|
||||
blockMenuSelected: "save" | "block" | "search" | "";
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "block">
|
||||
React.SetStateAction<"" | "save" | "block" | "search">
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ 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 { GraphSearchControl } from "@/components/edit/control/GraphSearchControl";
|
||||
import { IconUndo2, IconRedo2 } from "@/components/ui/icons";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { startTutorial } from "./tutorial";
|
||||
@@ -488,6 +489,74 @@ const FlowEditor: React.FC<{
|
||||
});
|
||||
}, [nodes, getViewport, setViewport]);
|
||||
|
||||
const navigateToNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
// Center the viewport on the selected node
|
||||
const zoom = 1.2; // Slightly zoom in for better visibility
|
||||
const nodeX = node.position.x + (node.width || 500) / 2;
|
||||
const nodeY = node.position.y + (node.height || 400) / 2;
|
||||
|
||||
setViewport({
|
||||
x: window.innerWidth / 2 - nodeX * zoom,
|
||||
y: window.innerHeight / 2 - nodeY * zoom,
|
||||
zoom: zoom,
|
||||
});
|
||||
|
||||
// Add a temporary highlight effect to the node
|
||||
updateNode(nodeId, {
|
||||
style: {
|
||||
...node.style,
|
||||
boxShadow: "0 0 20px 5px rgba(59, 130, 246, 0.8)",
|
||||
transition: "box-shadow 0.3s ease-in-out",
|
||||
},
|
||||
});
|
||||
|
||||
// Remove highlight after a delay
|
||||
setTimeout(() => {
|
||||
updateNode(nodeId, {
|
||||
style: {
|
||||
...node.style,
|
||||
boxShadow: undefined,
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
},
|
||||
[getNode, setViewport, updateNode],
|
||||
);
|
||||
|
||||
const highlightNode = useCallback(
|
||||
(nodeId: string | null) => {
|
||||
if (!nodeId) {
|
||||
// Clear all highlights
|
||||
nodes.forEach((node) => {
|
||||
updateNode(node.id, {
|
||||
style: {
|
||||
...node.style,
|
||||
boxShadow: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
// Add highlight effect without moving view
|
||||
updateNode(nodeId, {
|
||||
style: {
|
||||
...node.style,
|
||||
boxShadow: "0 0 15px 3px rgba(59, 130, 246, 0.6)",
|
||||
transition: "box-shadow 0.2s ease-in-out",
|
||||
},
|
||||
});
|
||||
},
|
||||
[getNode, updateNode, nodes],
|
||||
);
|
||||
|
||||
const addNode = useCallback(
|
||||
(blockId: string, nodeType: string, hardcodedValues: any = {}) => {
|
||||
const nodeSchema = availableBlocks.find((node) => node.id === blockId);
|
||||
@@ -682,6 +751,7 @@ const FlowEditor: React.FC<{
|
||||
}, [isScheduling, savedAgent, toast, saveAgent]);
|
||||
|
||||
const isNewBlockEnabled = useGetFlag(Flag.NEW_BLOCK_MENU);
|
||||
const isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -794,19 +864,31 @@ const FlowEditor: React.FC<{
|
||||
visualizeBeads={visualizeBeads}
|
||||
pinSavePopover={pinSavePopover}
|
||||
pinBlocksPopover={pinBlocksPopover}
|
||||
nodes={nodes}
|
||||
onNodeSelect={navigateToNode}
|
||||
onNodeHover={highlightNode}
|
||||
/>
|
||||
) : (
|
||||
<ControlPanel
|
||||
className="absolute z-20"
|
||||
controls={editorControls}
|
||||
topChildren={
|
||||
<BlocksControl
|
||||
pinBlocksPopover={pinBlocksPopover} // Pass the state to BlocksControl
|
||||
blocks={availableBlocks}
|
||||
addBlock={addNode}
|
||||
flows={availableFlows}
|
||||
nodes={nodes}
|
||||
/>
|
||||
<>
|
||||
<BlocksControl
|
||||
pinBlocksPopover={pinBlocksPopover} // Pass the state to BlocksControl
|
||||
blocks={availableBlocks}
|
||||
addBlock={addNode}
|
||||
flows={availableFlows}
|
||||
nodes={nodes}
|
||||
/>
|
||||
{isGraphSearchEnabled && (
|
||||
<GraphSearchControl
|
||||
nodes={nodes}
|
||||
onNodeSelect={navigateToNode}
|
||||
onNodeHover={highlightNode}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
botChildren={
|
||||
<SaveControl
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||
import { CustomNode } from "@/components/CustomNode";
|
||||
import { GraphSearchContent } from "../../../app/(platform)/build/components/NewBlockMenu/GraphMenuContent/GraphContent";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useGraphMenu } from "../../../app/(platform)/build/components/NewBlockMenu/GraphMenu/useGraphMenu";
|
||||
|
||||
interface GraphSearchControlProps {
|
||||
nodes: CustomNode[];
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string | null) => void;
|
||||
}
|
||||
|
||||
export function GraphSearchControl({
|
||||
nodes,
|
||||
onNodeSelect,
|
||||
onNodeHover,
|
||||
}: GraphSearchControlProps) {
|
||||
// Use the same hook as GraphSearchMenu for consistency
|
||||
const {
|
||||
open,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredNodes,
|
||||
handleNodeSelect,
|
||||
handleOpenChange,
|
||||
} = useGraphMenu({
|
||||
nodes,
|
||||
blockMenuSelected: "", // We don't need to track this in the old control panel
|
||||
setBlockMenuSelected: () => {}, // Not needed in this context
|
||||
onNodeSelect,
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-id="graph-search-control-trigger"
|
||||
data-testid="graph-search-control-button"
|
||||
name="Search"
|
||||
className="dark:hover:bg-slate-800"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Search Graph</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
side="right"
|
||||
sideOffset={22}
|
||||
align="start"
|
||||
alignOffset={-50} // Offset upward to align with control panel top
|
||||
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
|
||||
data-id="graph-search-popover-content"
|
||||
>
|
||||
<GraphSearchContent
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filteredNodes={filteredNodes}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNodeHover={onNodeHover}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export enum Flag {
|
||||
AGENT_ACTIVITY = "agent-activity",
|
||||
NEW_BLOCK_MENU = "new-block-menu",
|
||||
NEW_AGENT_RUNS = "new-agent-runs",
|
||||
GRAPH_SEARCH = "graph-search",
|
||||
}
|
||||
|
||||
export type FlagValues = {
|
||||
@@ -14,6 +15,7 @@ export type FlagValues = {
|
||||
[Flag.AGENT_ACTIVITY]: boolean;
|
||||
[Flag.NEW_BLOCK_MENU]: boolean;
|
||||
[Flag.NEW_AGENT_RUNS]: boolean;
|
||||
[Flag.GRAPH_SEARCH]: boolean;
|
||||
};
|
||||
|
||||
const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
@@ -23,6 +25,7 @@ const mockFlags = {
|
||||
[Flag.AGENT_ACTIVITY]: true,
|
||||
[Flag.NEW_BLOCK_MENU]: false,
|
||||
[Flag.NEW_AGENT_RUNS]: false,
|
||||
[Flag.GRAPH_SEARCH]: true,
|
||||
};
|
||||
|
||||
export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null {
|
||||
|
||||
Reference in New Issue
Block a user