mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-02 02:11:20 +00:00
Merge 88c1568b46 into cfa6c7cb17
This commit is contained in:
commit
82cddbb87a
8 changed files with 564 additions and 2 deletions
|
|
@ -51,6 +51,7 @@ function App() {
|
|||
- **Interactive canvas visualization** - Pan, zoom, and drag nodes using Canvas 2D rendering
|
||||
- **Document and memory nodes** - Documents as rectangles, memories as hexagons
|
||||
- **Relationship visualization** - Edges show document similarity and memory version chains
|
||||
- **Node search** - Find documents and memories by text, highlight matches, and step through them (press `/` to focus, `Enter` / `Shift+Enter` to cycle)
|
||||
- **Space filtering** - Filter by workspace or view all memories
|
||||
- **Two variants** - Full-featured console mode or embedded consumer mode
|
||||
- **Pagination support** - Keep the initial graph fitted as pages arrive; manual pan, zoom, or selection takes control of the view
|
||||
|
|
@ -66,6 +67,8 @@ function App() {
|
|||
| `error` | `Error \| null` | Error to display |
|
||||
| `loadMoreDocuments` | `() => Promise<void>` | Function to load more data |
|
||||
| `highlightDocumentIds` | `string[]` | IDs of documents to highlight |
|
||||
| `showSearch` | `boolean` | Show the in-graph node search box (default: `true`) |
|
||||
| `searchPlaceholder` | `string` | Placeholder text for the search box |
|
||||
|
||||
Console mode uses the supplied theme colors for its surface, 16px dot grid, document icons, and node fills and strokes. Consumer mode keeps its transparent surface and cluster colors.
|
||||
|
||||
|
|
|
|||
115
packages/memory-graph/src/__tests__/node-search.test.ts
Normal file
115
packages/memory-graph/src/__tests__/node-search.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import type { GraphNode } from "../types"
|
||||
import { searchNodes } from "../utils/node-search"
|
||||
|
||||
function docNode(
|
||||
id: string,
|
||||
title: string | null,
|
||||
summary: string | null = null,
|
||||
type = "document",
|
||||
): GraphNode {
|
||||
return {
|
||||
id,
|
||||
type: "document",
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 40,
|
||||
borderColor: "#fff",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
data: {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
type,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
memories: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function memoryNode(id: string, memory: string, content = ""): GraphNode {
|
||||
return {
|
||||
id,
|
||||
type: "memory",
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 24,
|
||||
borderColor: "#fff",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
data: {
|
||||
id,
|
||||
memory,
|
||||
content,
|
||||
documentId: "doc-x",
|
||||
isStatic: false,
|
||||
isLatest: true,
|
||||
isForgotten: false,
|
||||
forgetAfter: null,
|
||||
forgetReason: null,
|
||||
version: 1,
|
||||
parentMemoryId: null,
|
||||
spaceId: "space-1",
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("searchNodes", () => {
|
||||
it("returns nothing for an empty or whitespace query", () => {
|
||||
const nodes = [docNode("d1", "Alpha")]
|
||||
expect(searchNodes(nodes, "")).toEqual([])
|
||||
expect(searchNodes(nodes, " ")).toEqual([])
|
||||
})
|
||||
|
||||
it("matches document titles case-insensitively", () => {
|
||||
const nodes = [docNode("d1", "Quarterly Report"), docNode("d2", "Recipes")]
|
||||
const result = searchNodes(nodes, "REPORT")
|
||||
expect(result.map((n) => n.id)).toEqual(["d1"])
|
||||
})
|
||||
|
||||
it("matches memory text and content", () => {
|
||||
const nodes = [
|
||||
memoryNode("m1", "User prefers dark mode"),
|
||||
memoryNode("m2", "Lives in Berlin", "moved there in 2021"),
|
||||
]
|
||||
expect(searchNodes(nodes, "dark").map((n) => n.id)).toEqual(["m1"])
|
||||
expect(searchNodes(nodes, "2021").map((n) => n.id)).toEqual(["m2"])
|
||||
})
|
||||
|
||||
it("ranks primary-field matches above secondary-field matches", () => {
|
||||
// d2's summary contains the term; d1's title contains it. Title wins.
|
||||
const nodes = [
|
||||
docNode("d2", "Unrelated", "a note about invoices"),
|
||||
docNode("d1", "Invoices", "quarterly totals"),
|
||||
]
|
||||
expect(searchNodes(nodes, "invoice").map((n) => n.id)).toEqual(["d1", "d2"])
|
||||
})
|
||||
|
||||
it("ranks a prefix match above a mid-string match", () => {
|
||||
const nodes = [docNode("d1", "Redesign proposal"), docNode("d2", "Design")]
|
||||
expect(searchNodes(nodes, "design").map((n) => n.id)).toEqual(["d2", "d1"])
|
||||
})
|
||||
|
||||
it("keeps input order for equally-scored matches", () => {
|
||||
const nodes = [
|
||||
docNode("d1", "Design system"),
|
||||
docNode("d2", "Design tokens"),
|
||||
docNode("d3", "Design review"),
|
||||
]
|
||||
expect(searchNodes(nodes, "design").map((n) => n.id)).toEqual([
|
||||
"d1",
|
||||
"d2",
|
||||
"d3",
|
||||
])
|
||||
})
|
||||
|
||||
it("tolerates null titles and missing content without matching them", () => {
|
||||
const nodes = [docNode("d1", null, null), memoryNode("m1", "hello")]
|
||||
expect(searchNodes(nodes, "hello").map((n) => n.id)).toEqual(["m1"])
|
||||
expect(searchNodes(nodes, "null")).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Mounted-render verification for the in-graph search control.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
afterEach(cleanup)
|
||||
import { SearchControl } from "../components/search-control"
|
||||
import { DEFAULT_COLORS } from "../constants"
|
||||
|
||||
function renderControl(
|
||||
overrides: Partial<React.ComponentProps<typeof SearchControl>> = {},
|
||||
) {
|
||||
const props = {
|
||||
query: "",
|
||||
onQueryChange: vi.fn(),
|
||||
matchCount: 0,
|
||||
currentIndex: -1,
|
||||
onNext: vi.fn(),
|
||||
onPrev: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
colors: DEFAULT_COLORS,
|
||||
...overrides,
|
||||
}
|
||||
return { props, ...render(<SearchControl {...props} />) }
|
||||
}
|
||||
|
||||
describe("SearchControl mounted render", () => {
|
||||
it("hides the match counter until there is a query", () => {
|
||||
const { queryByText } = renderControl()
|
||||
expect(queryByText("0/0")).toBeNull()
|
||||
})
|
||||
|
||||
it("shows the 1-based position and total for the current match", () => {
|
||||
const { getByText } = renderControl({
|
||||
query: "design",
|
||||
matchCount: 3,
|
||||
currentIndex: 1,
|
||||
})
|
||||
getByText("2/3")
|
||||
})
|
||||
|
||||
it("reports zero results distinctly", () => {
|
||||
const { getByText } = renderControl({
|
||||
query: "zzz",
|
||||
matchCount: 0,
|
||||
currentIndex: 0,
|
||||
})
|
||||
getByText("0/0")
|
||||
})
|
||||
|
||||
it("forwards typing to onQueryChange", () => {
|
||||
const { props, getByLabelText } = renderControl()
|
||||
fireEvent.change(getByLabelText("Search graph nodes"), {
|
||||
target: { value: "berlin" },
|
||||
})
|
||||
expect(props.onQueryChange).toHaveBeenCalledWith("berlin")
|
||||
})
|
||||
|
||||
it("steps matches with Enter and Shift+Enter", () => {
|
||||
const { props, getByLabelText } = renderControl({
|
||||
query: "a",
|
||||
matchCount: 2,
|
||||
currentIndex: 0,
|
||||
})
|
||||
const input = getByLabelText("Search graph nodes")
|
||||
fireEvent.keyDown(input, { key: "Enter" })
|
||||
expect(props.onNext).toHaveBeenCalledTimes(1)
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: true })
|
||||
expect(props.onPrev).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("clears on Escape and on the clear button", () => {
|
||||
const { props, getByLabelText } = renderControl({
|
||||
query: "a",
|
||||
matchCount: 1,
|
||||
currentIndex: 0,
|
||||
})
|
||||
fireEvent.keyDown(getByLabelText("Search graph nodes"), { key: "Escape" })
|
||||
fireEvent.click(getByLabelText("Clear search"))
|
||||
expect(props.onClear).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("disables the step buttons when there are no matches", () => {
|
||||
const { getByLabelText } = renderControl({ query: "zzz", matchCount: 0 })
|
||||
expect((getByLabelText("Next match") as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
(getByLabelText("Previous match") as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -22,11 +22,13 @@ import type {
|
|||
MemoryGraphProps,
|
||||
ResolvedMemoryGraphLabels,
|
||||
} from "../types"
|
||||
import { searchNodes } from "../utils/node-search"
|
||||
import { GraphCanvas } from "./graph-canvas"
|
||||
import { Legend } from "./legend"
|
||||
import { LoadingIndicator } from "./loading-indicator"
|
||||
import { NavigationControls } from "./navigation-controls"
|
||||
import { NodeHoverPopover } from "./node-hover-popover"
|
||||
import { SearchControl } from "./search-control"
|
||||
|
||||
export function MemoryGraph({
|
||||
documents = [],
|
||||
|
|
@ -49,6 +51,8 @@ export function MemoryGraph({
|
|||
onOpenDocument,
|
||||
labels: labelOverrides,
|
||||
layering,
|
||||
showSearch = true,
|
||||
searchPlaceholder,
|
||||
}: MemoryGraphProps) {
|
||||
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
|
||||
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
|
||||
|
|
@ -77,6 +81,9 @@ export function MemoryGraph({
|
|||
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||
const [zoomDisplay, setZoomDisplay] = useState(50)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [searchIndex, setSearchIndex] = useState(0)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
// Monotonic counter that increments on any viewport change (pan or zoom)
|
||||
// Used as a dependency proxy to recalculate popover positions
|
||||
const [viewportVersion, setViewportVersion] = useState(0)
|
||||
|
|
@ -399,6 +406,12 @@ export function MemoryGraph({
|
|||
case "_":
|
||||
handleZoomOut()
|
||||
break
|
||||
case "/":
|
||||
if (showSearch) {
|
||||
e.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
setSelectedNode(null)
|
||||
break
|
||||
|
|
@ -407,7 +420,7 @@ export function MemoryGraph({
|
|||
|
||||
window.addEventListener("keydown", handler)
|
||||
return () => window.removeEventListener("keydown", handler)
|
||||
}, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut])
|
||||
}, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut, showSearch])
|
||||
|
||||
// Arrow key navigation through nodes
|
||||
const selectAndCenter = useCallback(
|
||||
|
|
@ -426,6 +439,61 @@ export function MemoryGraph({
|
|||
[nodes, containerSize.width, graphFitHeight, stopFollowing],
|
||||
)
|
||||
|
||||
// Node search — find documents/memories by text and step through matches.
|
||||
const searchMatches = useMemo(
|
||||
() => (showSearch ? searchNodes(nodes, searchQuery) : []),
|
||||
[showSearch, nodes, searchQuery],
|
||||
)
|
||||
const matchCount = searchMatches.length
|
||||
|
||||
// Keep the focused index in range as the match set changes (typing, data
|
||||
// reloads, pagination).
|
||||
useEffect(() => {
|
||||
setSearchIndex((i) => (matchCount === 0 ? 0 : Math.min(i, matchCount - 1)))
|
||||
}, [matchCount])
|
||||
|
||||
const currentMatchId = searchMatches[searchIndex]?.id ?? null
|
||||
|
||||
// Focus (select + center) the current match whenever it changes.
|
||||
useEffect(() => {
|
||||
if (currentMatchId) selectAndCenter(currentMatchId)
|
||||
}, [currentMatchId, selectAndCenter])
|
||||
|
||||
// Highlight the documents behind every match (memory matches highlight their
|
||||
// parent document) so the user can see where results sit, merged with any
|
||||
// externally-supplied highlights.
|
||||
const effectiveHighlightIds = useMemo(() => {
|
||||
if (!highlightsVisible) return []
|
||||
if (matchCount === 0) return highlightDocumentIds
|
||||
const ids = new Set(highlightDocumentIds)
|
||||
for (const node of searchMatches) {
|
||||
if (node.type === "document") ids.add(node.id)
|
||||
else if ("documentId" in node.data) ids.add(node.data.documentId)
|
||||
}
|
||||
return [...ids]
|
||||
}, [highlightsVisible, highlightDocumentIds, searchMatches, matchCount])
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchQuery(value)
|
||||
setSearchIndex(0)
|
||||
}, [])
|
||||
|
||||
const handleSearchNext = useCallback(() => {
|
||||
if (matchCount === 0) return
|
||||
setSearchIndex((i) => (i + 1) % matchCount)
|
||||
}, [matchCount])
|
||||
|
||||
const handleSearchPrev = useCallback(() => {
|
||||
if (matchCount === 0) return
|
||||
setSearchIndex((i) => (i - 1 + matchCount) % matchCount)
|
||||
}, [matchCount])
|
||||
|
||||
const handleSearchClear = useCallback(() => {
|
||||
setSearchQuery("")
|
||||
setSearchIndex(0)
|
||||
setSelectedNode(null)
|
||||
}, [])
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
const chain = chainIndex.current.getChain(selectedNode)
|
||||
|
|
@ -740,7 +808,7 @@ export function MemoryGraph({
|
|||
colors={colors}
|
||||
edges={edges}
|
||||
height={containerSize.height}
|
||||
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
|
||||
highlightDocumentIds={effectiveHighlightIds}
|
||||
nodes={nodes}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDragEnd={handleNodeDragEnd}
|
||||
|
|
@ -779,6 +847,21 @@ export function MemoryGraph({
|
|||
|
||||
{containerSize.width > 0 && (
|
||||
<div style={bottomLeftStackStyle}>
|
||||
{showSearch && nodes.length > 0 && (
|
||||
<SearchControl
|
||||
colors={colors}
|
||||
compact={isCompactViewport}
|
||||
currentIndex={searchIndex}
|
||||
inputRef={searchInputRef}
|
||||
matchCount={matchCount}
|
||||
onClear={handleSearchClear}
|
||||
onNext={handleSearchNext}
|
||||
onPrev={handleSearchPrev}
|
||||
onQueryChange={handleSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
query={searchQuery}
|
||||
/>
|
||||
)}
|
||||
<NavigationControls
|
||||
nodes={nodes}
|
||||
compact={isCompactViewport}
|
||||
|
|
|
|||
183
packages/memory-graph/src/components/search-control.tsx
Normal file
183
packages/memory-graph/src/components/search-control.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { memo } from "react"
|
||||
import type { GraphThemeColors } from "../types"
|
||||
|
||||
interface SearchControlProps {
|
||||
query: string
|
||||
onQueryChange: (query: string) => void
|
||||
/** Number of nodes matching the current query. */
|
||||
matchCount: number
|
||||
/** Zero-based index of the focused match, or -1 when there is none. */
|
||||
currentIndex: number
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
onClear: () => void
|
||||
colors: GraphThemeColors
|
||||
compact?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
function StepButton({
|
||||
onClick,
|
||||
disabled,
|
||||
label,
|
||||
colors,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
label: string
|
||||
colors: GraphThemeColors
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const style: React.CSSProperties = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
color: colors.textSecondary,
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
fontSize: 12,
|
||||
padding: 0,
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
transition: "opacity 0.15s",
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
type="button"
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) e.currentTarget.style.opacity = "0.8"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled) e.currentTarget.style.opacity = "1"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const SearchControl = memo<SearchControlProps>(
|
||||
({
|
||||
query,
|
||||
onQueryChange,
|
||||
matchCount,
|
||||
currentIndex,
|
||||
onNext,
|
||||
onPrev,
|
||||
onClear,
|
||||
colors,
|
||||
compact = false,
|
||||
placeholder = "Search nodes",
|
||||
inputRef,
|
||||
}) => {
|
||||
const hasQuery = query.trim().length > 0
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "fit-content",
|
||||
maxWidth: compact ? "calc(100vw - 32px)" : 260,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 8,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
borderRadius: 9999,
|
||||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)",
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
width: compact ? 120 : 150,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: colors.textPrimary,
|
||||
fontSize: 12,
|
||||
padding: 0,
|
||||
}
|
||||
|
||||
const countStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color:
|
||||
hasQuery && matchCount === 0 ? colors.textMuted : colors.textSecondary,
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 34,
|
||||
textAlign: "right",
|
||||
}
|
||||
|
||||
const countLabel = !hasQuery
|
||||
? ""
|
||||
: matchCount === 0
|
||||
? "0/0"
|
||||
: `${currentIndex + 1}/${matchCount}`
|
||||
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<input
|
||||
aria-label="Search graph nodes"
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) onPrev()
|
||||
else onNext()
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
onClear()
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
style={inputStyle}
|
||||
type="text"
|
||||
value={query}
|
||||
/>
|
||||
|
||||
{hasQuery && <span style={countStyle}>{countLabel}</span>}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<StepButton
|
||||
colors={colors}
|
||||
disabled={matchCount === 0}
|
||||
label="Previous match"
|
||||
onClick={onPrev}
|
||||
>
|
||||
<span style={{ fontSize: 11 }}>↑</span>
|
||||
</StepButton>
|
||||
<StepButton
|
||||
colors={colors}
|
||||
disabled={matchCount === 0}
|
||||
label="Next match"
|
||||
onClick={onNext}
|
||||
>
|
||||
<span style={{ fontSize: 11 }}>↓</span>
|
||||
</StepButton>
|
||||
<StepButton
|
||||
colors={colors}
|
||||
disabled={!hasQuery}
|
||||
label="Clear search"
|
||||
onClick={onClear}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>×</span>
|
||||
</StepButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SearchControl.displayName = "SearchControl"
|
||||
|
|
@ -6,6 +6,9 @@ export { GraphCanvas } from "./components/graph-canvas"
|
|||
export { useGraphData } from "./hooks/use-graph-data"
|
||||
export { useGraphTheme } from "./hooks/use-graph-theme"
|
||||
|
||||
// Utilities
|
||||
export { searchNodes } from "./utils/node-search"
|
||||
|
||||
// Engine classes (for advanced usage)
|
||||
export { ForceSimulation } from "./canvas/simulation"
|
||||
export { ViewportState } from "./canvas/viewport"
|
||||
|
|
|
|||
|
|
@ -224,6 +224,10 @@ export interface MemoryGraphProps {
|
|||
labels?: MemoryGraphLabels
|
||||
/** Overlay layering controls */
|
||||
layering?: MemoryGraphLayering
|
||||
/** Show the in-graph node search box (default: true) */
|
||||
showSearch?: boolean
|
||||
/** Placeholder text for the search box */
|
||||
searchPlaceholder?: string
|
||||
}
|
||||
|
||||
export interface ChainEntry {
|
||||
|
|
|
|||
75
packages/memory-graph/src/utils/node-search.ts
Normal file
75
packages/memory-graph/src/utils/node-search.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { GraphNode } from "../types"
|
||||
|
||||
/**
|
||||
* Relevance tiers for a single node match, highest first. A node's overall
|
||||
* score is the best tier any of its fields achieves for the query.
|
||||
*/
|
||||
const SCORE_PRIMARY_PREFIX = 3
|
||||
const SCORE_PRIMARY_INCLUDES = 2
|
||||
const SCORE_SECONDARY_INCLUDES = 1
|
||||
const SCORE_NONE = 0
|
||||
|
||||
/**
|
||||
* The text fields that make a node findable, split into a primary field (the
|
||||
* node's headline: a document title or a memory's text) and secondary fields
|
||||
* (supporting copy that should still match but rank lower).
|
||||
*/
|
||||
function getNodeSearchFields(node: GraphNode): {
|
||||
primary: string
|
||||
secondary: string
|
||||
} {
|
||||
if (node.type === "document") {
|
||||
const data = node.data as {
|
||||
title: string | null
|
||||
summary: string | null
|
||||
type: string
|
||||
}
|
||||
return {
|
||||
primary: data.title ?? "",
|
||||
secondary: `${data.summary ?? ""} ${data.type ?? ""}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = node.data as { memory: string; content: string }
|
||||
return {
|
||||
primary: data.memory ?? "",
|
||||
secondary: data.content ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
function scoreNode(node: GraphNode, query: string): number {
|
||||
const { primary, secondary } = getNodeSearchFields(node)
|
||||
const primaryLower = primary.toLowerCase()
|
||||
|
||||
if (primaryLower.startsWith(query)) return SCORE_PRIMARY_PREFIX
|
||||
if (primaryLower.includes(query)) return SCORE_PRIMARY_INCLUDES
|
||||
if (secondary.toLowerCase().includes(query)) return SCORE_SECONDARY_INCLUDES
|
||||
return SCORE_NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nodes whose text matches `rawQuery`, ordered by relevance.
|
||||
*
|
||||
* Matching is a case-insensitive substring test across each node's title/memory
|
||||
* (primary) and summary/type/content (secondary) text. Results are sorted by
|
||||
* relevance tier — primary-prefix, then primary-substring, then
|
||||
* secondary-substring — and ties keep the input order so the result is stable
|
||||
* and independent of the force-simulation's node positions.
|
||||
*
|
||||
* An empty or whitespace-only query returns no matches.
|
||||
*/
|
||||
export function searchNodes(nodes: GraphNode[], rawQuery: string): GraphNode[] {
|
||||
const query = rawQuery.trim().toLowerCase()
|
||||
if (!query) return []
|
||||
|
||||
const scored: { node: GraphNode; score: number; index: number }[] = []
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const node = nodes[index]
|
||||
if (!node) continue
|
||||
const score = scoreNode(node, query)
|
||||
if (score > SCORE_NONE) scored.push({ node, score, index })
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
return scored.map((entry) => entry.node)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue