Skip to content

Commit 9cfa191

Browse files
committed
feat: harmonize node documentation with node selector
1 parent 30ea535 commit 9cfa191

2 files changed

Lines changed: 192 additions & 32 deletions

File tree

apps/web/src/components/docs/node-card.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ import { NodeDocsDialog } from "./node-docs-dialog";
1212
interface NodeCardProps {
1313
nodeType: NodeType;
1414
variant?: "card" | "list";
15+
searchQuery?: string;
16+
highlightMatch?: (text: string, searchTerm: string) => React.ReactNode;
1517
}
1618

17-
export function NodeCard({ nodeType, variant = "card" }: NodeCardProps) {
19+
export function NodeCard({
20+
nodeType,
21+
variant = "card",
22+
searchQuery = "",
23+
highlightMatch,
24+
}: NodeCardProps) {
1825
const [isDialogOpen, setIsDialogOpen] = useState(false);
1926

2027
if (variant === "list") {
@@ -33,7 +40,7 @@ export function NodeCard({ nodeType, variant = "card" }: NodeCardProps) {
3340
className="h-4 w-4 text-blue-500 shrink-0"
3441
/>
3542
<CardTitle className="text-base font-semibold leading-tight truncate">
36-
{nodeType.name}
43+
{highlightMatch ? highlightMatch(nodeType.name, searchQuery) : nodeType.name}
3744
</CardTitle>
3845
{nodeType.tags.map((tag, index) => (
3946
<Badge
@@ -50,7 +57,7 @@ export function NodeCard({ nodeType, variant = "card" }: NodeCardProps) {
5057
{nodeType.description && (
5158
<div className="hidden md:block flex-1 min-w-0">
5259
<p className="text-sm text-muted-foreground leading-relaxed truncate">
53-
{nodeType.description}
60+
{highlightMatch ? highlightMatch(nodeType.description, searchQuery) : nodeType.description}
5461
</p>
5562
</div>
5663
)}
@@ -93,7 +100,7 @@ export function NodeCard({ nodeType, variant = "card" }: NodeCardProps) {
93100
className="h-4 w-4 text-blue-500 shrink-0"
94101
/>
95102
<CardTitle className="text-base font-semibold leading-tight truncate">
96-
{nodeType.name}
103+
{highlightMatch ? highlightMatch(nodeType.name, searchQuery) : nodeType.name}
97104
</CardTitle>
98105
</div>
99106
<NodeTags
@@ -105,7 +112,7 @@ export function NodeCard({ nodeType, variant = "card" }: NodeCardProps) {
105112
<CardContent className="pt-0">
106113
{nodeType.description && (
107114
<p className="text-sm text-muted-foreground leading-relaxed max-h-16 overflow-hidden">
108-
{nodeType.description}
115+
{highlightMatch ? highlightMatch(nodeType.description, searchQuery) : nodeType.description}
109116
</p>
110117
)}
111118
{nodeType.compatibility && nodeType.compatibility.length > 0 && (

apps/web/src/components/docs/nodes-browser.tsx

Lines changed: 180 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,51 +7,201 @@ import { Button } from "@/components/ui/button";
77
import { SearchInput } from "@/components/ui/search-input";
88
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
99
import { TagFilterButtons } from "@/components/ui/tag-filter-buttons";
10-
import { useSearch } from "@/hooks/use-search";
1110
import { useTagCounts } from "@/hooks/use-tag-counts";
1211
import { useNodeTypes } from "@/services/type-service";
12+
import { normalizeText } from "@/utils/text-normalization";
1313

1414
import { NodeCard } from "./node-card";
1515

1616
type 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+
1851
export 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

Comments
 (0)