feat(dashboard): wire core SearchEngine with fuzzy matching and scored highlighting

Replace manual substring search in Zustand store with core SearchEngine
(Fuse.js fuzzy matching). SearchBar now shows a top-5 dropdown with type
badges and relevance bars; closes on Escape/outside click. GraphView
updated to work with SearchResult[] (nodeId+score) instead of string[].
Added sub-path exports to core package.json so the dashboard can import
search/types modules without pulling in Node.js-only dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 19:09:57 +08:00
Unverified
parent b6ab0f8b32
commit 4f6214a5ce
4 changed files with 170 additions and 40 deletions
+14
View File
@@ -4,6 +4,20 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./search": {
"types": "./dist/search.d.ts",
"default": "./dist/search.js"
},
"./types": {
"types": "./dist/types.d.ts",
"default": "./dist/types.js"
}
},
"scripts": {
"build": "tsc",
"test": "vitest run"
@@ -35,7 +35,7 @@ export default function GraphView() {
nodeType: node.type,
summary: node.summary,
complexity: node.complexity,
isHighlighted: searchResults.includes(node.id),
isHighlighted: searchResults.some((r) => r.nodeId === node.id),
isSelected: selectedNodeId === node.id,
},
}));
+139 -24
View File
@@ -1,36 +1,151 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useDashboardStore } from "../store";
const typeBadgeColors: Record<string, string> = {
file: "bg-blue-700 text-blue-200",
function: "bg-green-700 text-green-200",
class: "bg-purple-700 text-purple-200",
module: "bg-orange-700 text-orange-200",
concept: "bg-pink-700 text-pink-200",
};
export default function SearchBar() {
const searchQuery = useDashboardStore((s) => s.searchQuery);
const searchResults = useDashboardStore((s) => s.searchResults);
const graph = useDashboardStore((s) => s.graph);
const setSearchQuery = useDashboardStore((s) => s.setSearchQuery);
const selectNode = useDashboardStore((s) => s.selectNode);
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Build a lookup map for node details
const nodeMap = new Map(
(graph?.nodes ?? []).map((n) => [n.id, n]),
);
const topResults = searchResults.slice(0, 5);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setDropdownOpen(true);
},
[setSearchQuery],
);
const handleResultClick = useCallback(
(nodeId: string) => {
selectNode(nodeId);
setDropdownOpen(false);
},
[selectNode],
);
// Close dropdown on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setDropdownOpen(false);
inputRef.current?.blur();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const showDropdown = dropdownOpen && searchQuery.trim() && topResults.length > 0;
return (
<div className="flex items-center gap-2 px-4 py-2 bg-gray-800 border-b border-gray-700">
<svg
className="w-4 h-4 text-gray-400 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
<div ref={containerRef} className="relative z-10">
<div className="flex items-center gap-2 px-4 py-2 bg-gray-800 border-b border-gray-700">
<svg
className="w-4 h-4 text-gray-400 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={handleInputChange}
onFocus={() => setDropdownOpen(true)}
placeholder="Search nodes by name, summary, or tags..."
className="flex-1 bg-gray-700 text-white text-sm rounded px-3 py-1.5 border border-gray-600 focus:outline-none focus:border-blue-500 placeholder-gray-400"
/>
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search nodes by name, summary, or tags..."
className="flex-1 bg-gray-700 text-white text-sm rounded px-3 py-1.5 border border-gray-600 focus:outline-none focus:border-blue-500 placeholder-gray-400"
/>
{searchQuery.trim() && (
<span className="text-xs text-gray-400 shrink-0">
{searchResults.length} result{searchResults.length !== 1 ? "s" : ""}
</span>
{searchQuery.trim() && (
<span className="text-xs text-gray-400 shrink-0">
{searchResults.length} result{searchResults.length !== 1 ? "s" : ""}{" "}
<span className="text-gray-500">(fuzzy)</span>
</span>
)}
</div>
{/* Dropdown results */}
{showDropdown && (
<div className="absolute left-4 right-4 top-full mt-0.5 bg-gray-800 border border-gray-600 rounded-lg shadow-xl overflow-hidden">
{topResults.map((result) => {
const node = nodeMap.get(result.nodeId);
if (!node) return null;
const relevance = Math.round((1 - result.score) * 100);
const badgeColor = typeBadgeColors[node.type] ?? typeBadgeColors.file;
return (
<button
key={result.nodeId}
type="button"
onClick={() => handleResultClick(result.nodeId)}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-gray-700 transition-colors text-left"
>
{/* Type badge */}
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${badgeColor} shrink-0`}
>
{node.type}
</span>
{/* Node name */}
<span className="text-sm text-white truncate flex-1">
{node.name}
</span>
{/* Relevance bar */}
<div className="flex items-center gap-1.5 shrink-0">
<div className="w-16 h-1.5 bg-gray-600 rounded-full overflow-hidden">
<div
className="h-full bg-blue-400 rounded-full"
style={{ width: `${relevance}%` }}
/>
</div>
<span className="text-[10px] text-gray-400 w-7 text-right">
{relevance}%
</span>
</div>
</button>
);
})}
</div>
)}
</div>
);
+16 -15
View File
@@ -1,11 +1,14 @@
import { create } from "zustand";
import type { KnowledgeGraph } from "@understand-anything/core";
import { SearchEngine } from "@understand-anything/core/search";
import type { SearchResult } from "@understand-anything/core/search";
import type { KnowledgeGraph } from "@understand-anything/core/types";
interface DashboardStore {
graph: KnowledgeGraph | null;
selectedNodeId: string | null;
searchQuery: string;
searchResults: string[]; // node IDs
searchResults: SearchResult[];
searchEngine: SearchEngine | null;
setGraph: (graph: KnowledgeGraph) => void;
selectNode: (nodeId: string | null) => void;
@@ -17,24 +20,22 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
selectedNodeId: null,
searchQuery: "",
searchResults: [],
searchEngine: null,
setGraph: (graph) => set({ graph }),
setGraph: (graph) => {
const searchEngine = new SearchEngine(graph.nodes);
const query = get().searchQuery;
const searchResults = query.trim() ? searchEngine.search(query) : [];
set({ graph, searchEngine, searchResults });
},
selectNode: (nodeId) => set({ selectedNodeId: nodeId }),
setSearchQuery: (query) => {
const graph = get().graph;
if (!graph || !query.trim()) {
const engine = get().searchEngine;
if (!engine || !query.trim()) {
set({ searchQuery: query, searchResults: [] });
return;
}
const lower = query.toLowerCase();
const results = graph.nodes
.filter(
(node) =>
node.name.toLowerCase().includes(lower) ||
node.summary.toLowerCase().includes(lower) ||
node.tags.some((tag) => tag.toLowerCase().includes(lower)),
)
.map((n) => n.id);
set({ searchQuery: query, searchResults: results });
const searchResults = engine.search(query);
set({ searchQuery: query, searchResults });
},
}));