@@ -7,51 +7,201 @@ import { Button } from "@/components/ui/button";
77import { SearchInput } from "@/components/ui/search-input" ;
88import { Tabs , TabsList , TabsTrigger } from "@/components/ui/tabs" ;
99import { TagFilterButtons } from "@/components/ui/tag-filter-buttons" ;
10- import { useSearch } from "@/hooks/use-search" ;
1110import { useTagCounts } from "@/hooks/use-tag-counts" ;
1211import { useNodeTypes } from "@/services/type-service" ;
12+ import { normalizeText } from "@/utils/text-normalization" ;
1313
1414import { NodeCard } from "./node-card" ;
1515
1616type ViewMode = "card" | "list" ;
1717
18+ // Helper function to highlight matching text
19+ function highlightMatch ( text : string , searchTerm : string ) {
20+ if ( ! searchTerm . trim ( ) ) return text ;
21+
22+ // Split search term into individual words and escape special regex characters
23+ const words = searchTerm
24+ . trim ( )
25+ . split ( / \s + / )
26+ . map ( ( word ) => word . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) )
27+ . filter ( ( word ) => word . length > 0 ) ;
28+
29+ if ( words . length === 0 ) return text ;
30+
31+ // Create a regex that matches any of the words
32+ const regex = new RegExp ( `(${ words . join ( "|" ) } )` , "gi" ) ;
33+ const parts = text . split ( regex ) ;
34+
35+ return parts . map ( ( part , index ) => {
36+ // Check if this part matches any of the search words
37+ if ( words . some ( ( word ) => new RegExp ( `^${ word } $` , "i" ) . test ( part ) ) ) {
38+ return (
39+ < mark
40+ key = { index }
41+ className = "bg-yellow-200 dark:bg-yellow-900 font-semibold"
42+ >
43+ { part }
44+ </ mark >
45+ ) ;
46+ }
47+ return part ;
48+ } ) ;
49+ }
50+
1851export function NodesBrowser ( ) {
1952 const { nodeTypes, isNodeTypesLoading, nodeTypesError } = useNodeTypes ( ) ;
2053 const [ searchQuery , setSearchQuery ] = useState ( "" ) ;
21- const [ selectedTag , setSelectedTag ] = useState < string | null > ( null ) ;
54+ const [ selectedTags , setSelectedTags ] = useState < string [ ] > ( [ ] ) ;
2255 const [ viewMode , setViewMode ] = useState < ViewMode > ( "card" ) ;
2356
24- // Use the search hook with intelligent search
25- const searchResults = useSearch ( {
26- items : nodeTypes ,
27- searchQuery,
28- searchFields : ( nodeType ) => [
29- nodeType . name ,
30- nodeType . description || "" ,
31- ...nodeType . tags ,
32- nodeType . type ,
33- ] ,
34- } ) ;
57+ // Combined scoring using substring matching (not fuzzy)
58+ const scoredAndFilteredNodes = useMemo ( ( ) => {
59+ // Normalize search query (for scoring only - no filtering)
60+ const searchKeywords = normalizeText ( searchQuery , {
61+ removeStopWords : true ,
62+ useLemmatization : true ,
63+ minTokenLength : 2 ,
64+ } ) ;
65+
66+ // Also keep original search term for partial matching
67+ const rawSearchTerm = searchQuery . toLowerCase ( ) . trim ( ) ;
68+
69+ // Helper function for partial word matching
70+ const containsPartialMatch = (
71+ text : string ,
72+ searchTerm : string
73+ ) : boolean => {
74+ if ( ! searchTerm ) return false ;
75+ return text . toLowerCase ( ) . includes ( searchTerm ) ;
76+ } ;
77+
78+ // Score each node type
79+ const scored = nodeTypes
80+ . map ( ( nodeType ) => {
81+ let score = 0 ;
82+
83+ // Normalize node type fields
84+ const nameTokens = new Set (
85+ normalizeText ( nodeType . name , {
86+ removeStopWords : false , // Keep all words in name
87+ useLemmatization : true ,
88+ minTokenLength : 2 ,
89+ } )
90+ ) ;
91+ const descTokens = new Set (
92+ normalizeText ( nodeType . description || "" , {
93+ removeStopWords : true ,
94+ useLemmatization : true ,
95+ minTokenLength : 2 ,
96+ } )
97+ ) ;
98+ const tagTokens = new Set (
99+ nodeType . tags . flatMap ( ( tag ) =>
100+ normalizeText ( tag , {
101+ removeStopWords : false ,
102+ useLemmatization : true ,
103+ minTokenLength : 2 ,
104+ } )
105+ )
106+ ) ;
35107
36- // Get tag counts (using all tags, not just the first one)
37- const tagCounts = useTagCounts ( nodeTypes ) ;
108+ // Score based on search keywords (if present) - exact token match
109+ searchKeywords . forEach ( ( keyword ) => {
110+ if ( nameTokens . has ( keyword ) ) score += 20 ;
111+ if ( descTokens . has ( keyword ) ) score += 10 ;
112+ if ( tagTokens . has ( keyword ) ) score += 15 ;
113+ } ) ;
38114
39- // Filter nodes based on search results and selected tag
115+ // Bonus scoring for partial matches in search term (substring matching)
116+ if ( rawSearchTerm ) {
117+ if ( containsPartialMatch ( nodeType . name , rawSearchTerm ) ) score += 15 ;
118+ if ( containsPartialMatch ( nodeType . description || "" , rawSearchTerm ) )
119+ score += 8 ;
120+ nodeType . tags . forEach ( ( tag ) => {
121+ if ( containsPartialMatch ( tag , rawSearchTerm ) ) score += 12 ;
122+ } ) ;
123+ }
124+
125+ // Check if matches search filter (partial or exact match)
126+ const matchesSearch =
127+ ! rawSearchTerm ||
128+ containsPartialMatch ( nodeType . name , rawSearchTerm ) ||
129+ containsPartialMatch ( nodeType . description || "" , rawSearchTerm ) ||
130+ nodeType . tags . some ( ( tag ) =>
131+ containsPartialMatch ( tag , rawSearchTerm )
132+ ) ||
133+ searchKeywords . some (
134+ ( keyword ) =>
135+ nameTokens . has ( keyword ) ||
136+ descTokens . has ( keyword ) ||
137+ tagTokens . has ( keyword )
138+ ) ;
139+
140+ return { nodeType, score, matchesSearch } ;
141+ } )
142+ . filter ( ( s ) => s . matchesSearch ) // Filter by search query
143+ . sort ( ( a , b ) => {
144+ // Sort by score (highest first), then alphabetically
145+ if ( b . score !== a . score ) return b . score - a . score ;
146+ return a . nodeType . name . localeCompare ( b . nodeType . name ) ;
147+ } ) ;
148+
149+ return {
150+ sorted : scored . map ( ( s ) => s . nodeType ) ,
151+ scores : scored ,
152+ } ;
153+ } , [ nodeTypes , searchQuery ] ) ;
154+
155+ // Filter nodes based on selected tags (all must match)
40156 const filteredNodes = useMemo ( ( ) => {
41- let filtered = searchResults ;
157+ return scoredAndFilteredNodes . sorted . filter ( ( nodeType ) => {
158+ // If no tags selected, show all
159+ if ( selectedTags . length === 0 ) return true ;
42160
43- // Filter by tag (check if any tag matches)
44- if ( selectedTag ) {
45- filtered = filtered . filter ( ( nodeType ) => {
161+ // Check if all selected tags match
162+ return selectedTags . every ( ( selectedTag ) => {
46163 if ( selectedTag === "Tools" ) {
47164 return ! ! nodeType . functionCalling ;
48165 }
49166 return nodeType . tags . includes ( selectedTag ) ;
50167 } ) ;
51- }
168+ } ) ;
169+ } , [ scoredAndFilteredNodes . sorted , selectedTags ] ) ;
52170
53- return filtered . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) ;
54- } , [ searchResults , selectedTag ] ) ;
171+ // Get overall tag counts (for display)
172+ const overallTagCounts = useTagCounts ( scoredAndFilteredNodes . sorted ) ;
173+
174+ // Get filtered tag counts (for discrimination/ordering)
175+ const filteredTagCounts = useTagCounts ( filteredNodes ) ;
176+
177+ // Combine: use filtered counts for ordering, but display overall counts
178+ // Show top 20 tags by filtered count
179+ const tagCounts = filteredTagCounts . slice ( 0 , 20 ) . map ( ( { tag } ) => {
180+ const overallCount =
181+ overallTagCounts . find ( ( tc ) => tc . tag === tag ) ?. count ?? 0 ;
182+ return { tag, count : overallCount } ;
183+ } ) ;
184+
185+ // Get overall counts for selected tags (sorted by count desc, then alphabetically)
186+ const selectedTagCounts = overallTagCounts
187+ . filter ( ( tc ) => selectedTags . includes ( tc . tag ) )
188+ . sort ( ( a , b ) => {
189+ if ( b . count !== a . count ) return b . count - a . count ;
190+ return a . tag . localeCompare ( b . tag ) ;
191+ } ) ;
192+
193+ // Handle tag change
194+ const handleTagChange = ( tag : string | null ) => {
195+ if ( tag === null ) {
196+ setSelectedTags ( [ ] ) ;
197+ } else if ( selectedTags . includes ( tag ) ) {
198+ // Remove tag if already selected
199+ setSelectedTags ( selectedTags . filter ( ( t ) => t !== tag ) ) ;
200+ } else {
201+ // Add tag if not selected
202+ setSelectedTags ( [ ...selectedTags , tag ] ) ;
203+ }
204+ } ;
55205
56206 if ( nodeTypesError ) {
57207 return (
@@ -99,9 +249,10 @@ export function NodesBrowser() {
99249 { /* Tag Filter */ }
100250 < TagFilterButtons
101251 categories = { tagCounts }
102- selectedTag = { selectedTag }
103- onTagChange = { setSelectedTag }
104- totalCount = { nodeTypes . length }
252+ selectedTags = { selectedTags }
253+ selectedTagCounts = { selectedTagCounts }
254+ onTagChange = { handleTagChange }
255+ totalCount = { scoredAndFilteredNodes . sorted . length }
105256 />
106257 </ div >
107258
@@ -135,7 +286,7 @@ export function NodesBrowser() {
135286 className = "mt-2"
136287 onClick = { ( ) => {
137288 setSearchQuery ( "" ) ;
138- setSelectedTag ( null ) ;
289+ setSelectedTags ( [ ] ) ;
139290 } }
140291 >
141292 Clear filters
@@ -157,6 +308,8 @@ export function NodesBrowser() {
157308 key = { nodeType . id }
158309 nodeType = { nodeType }
159310 variant = { viewMode }
311+ searchQuery = { searchQuery }
312+ highlightMatch = { highlightMatch }
160313 />
161314 ) ) }
162315 </ div >
0 commit comments