mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-09 03:18:04 +00:00
Fix large consumer memory graph loading
This commit is contained in:
parent
73f9c16df1
commit
22367a1725
6 changed files with 178 additions and 63 deletions
|
|
@ -673,7 +673,7 @@ export default function NewPage() {
|
|||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
) : viewMode === "graph" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<GraphLayoutView onOpenDocument={handleOpenDocumentById} />
|
||||
</div>
|
||||
) : viewMode === "list" ? (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { dmSansClassName } from "@/lib/fonts"
|
|||
import { ShareModal } from "./share-modal"
|
||||
import { shareParam } from "@/lib/search-params"
|
||||
|
||||
const GRAPH_MAX_NODES = 3000
|
||||
|
||||
export const GraphLayoutView = memo(function GraphLayoutView({
|
||||
onOpenDocument,
|
||||
}: {
|
||||
|
|
@ -35,15 +37,15 @@ export const GraphLayoutView = memo(function GraphLayoutView({
|
|||
}, [setIsShareModalOpen])
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[calc(100dvh-8.5rem)] w-full md:min-h-0">
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col w-full">
|
||||
{/* Full-width graph */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<MemoryGraph
|
||||
containerTags={effectiveContainerTags}
|
||||
variant="consumer"
|
||||
highlightDocumentIds={allHighlightDocumentIds}
|
||||
highlightsVisible
|
||||
maxNodes={undefined}
|
||||
maxNodes={GRAPH_MAX_NODES}
|
||||
canvasRef={canvasRef}
|
||||
onOpenDocument={onOpenDocument}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { useMemo } from "react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type {
|
||||
GraphApiDocument,
|
||||
|
|
@ -15,6 +15,7 @@ interface UseGraphApiOptions {
|
|||
containerTags?: string[]
|
||||
documentIds?: string[]
|
||||
enabled?: boolean
|
||||
maxNodes?: number
|
||||
}
|
||||
|
||||
interface ApiMemoryEntry {
|
||||
|
|
@ -59,6 +60,13 @@ interface ApiDocumentsResponse {
|
|||
}
|
||||
}
|
||||
|
||||
function getGraphNodeCount(documents: ApiDocument[]): number {
|
||||
return documents.reduce(
|
||||
(total, doc) => total + 1 + (doc.memoryEntries?.length ?? 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
|
||||
return {
|
||||
id: mem.id,
|
||||
|
|
@ -108,7 +116,7 @@ function toGraphDocument(
|
|||
}
|
||||
|
||||
export function useGraphApi(options: UseGraphApiOptions = {}) {
|
||||
const { containerTags, documentIds, enabled = true } = options
|
||||
const { containerTags, documentIds, enabled = true, maxNodes } = options
|
||||
const filteredDocumentIds = documentIds?.filter(Boolean)
|
||||
const hasDocumentIds =
|
||||
filteredDocumentIds != null && filteredDocumentIds.length > 0
|
||||
|
|
@ -126,6 +134,7 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
|
|||
containerTags,
|
||||
[],
|
||||
filteredDocumentIds,
|
||||
maxNodes,
|
||||
],
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
|
|
@ -155,7 +164,16 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
|
|||
|
||||
return response.data as unknown as ApiDocumentsResponse
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (hasDocumentIds) return undefined
|
||||
if (maxNodes != null) {
|
||||
const loadedNodes = allPages.reduce(
|
||||
(total, page) => total + getGraphNodeCount(page.documents ?? []),
|
||||
0,
|
||||
)
|
||||
if (loadedNodes >= maxNodes) return undefined
|
||||
}
|
||||
|
||||
const { currentPage, totalPages } = lastPage.pagination
|
||||
return currentPage < totalPages ? currentPage + 1 : undefined
|
||||
},
|
||||
|
|
@ -163,6 +181,29 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
|
|||
enabled,
|
||||
})
|
||||
|
||||
const loadedNodeCount = useMemo(() => {
|
||||
if (!data?.pages) return 0
|
||||
return data.pages.reduce(
|
||||
(total, page) => total + getGraphNodeCount(page.documents ?? []),
|
||||
0,
|
||||
)
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || hasDocumentIds || maxNodes == null) return
|
||||
if (!hasNextPage || isFetchingNextPage || loadedNodeCount >= maxNodes)
|
||||
return
|
||||
fetchNextPage()
|
||||
}, [
|
||||
enabled,
|
||||
hasDocumentIds,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
loadedNodeCount,
|
||||
maxNodes,
|
||||
fetchNextPage,
|
||||
])
|
||||
|
||||
const documents = useMemo(() => {
|
||||
if (!data?.pages) return []
|
||||
return data.pages.flatMap((page) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
|
||||
import type { GraphThemeColors } from "@supermemory/memory-graph"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
|
|
@ -34,20 +33,6 @@ export function MemoryGraph({
|
|||
canvasRef,
|
||||
...rest
|
||||
}: MemoryGraphWrapperProps) {
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
})
|
||||
ro.observe(el)
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const {
|
||||
documents,
|
||||
isLoading: apiIsLoading,
|
||||
|
|
@ -59,11 +44,11 @@ export function MemoryGraph({
|
|||
} = useGraphApi({
|
||||
containerTags,
|
||||
documentIds,
|
||||
enabled: containerSize.width > 0 && containerSize.height > 0,
|
||||
maxNodes,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="size-full [&>div]:!bg-none">
|
||||
<div className="absolute inset-0 [&>div]:!h-full [&>div]:!bg-none">
|
||||
<MemoryGraphBase
|
||||
documents={documents}
|
||||
isLoading={externalIsLoading || apiIsLoading}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { VersionChainIndex } from "../canvas/version-chain"
|
|||
import type { ViewportState } from "../canvas/viewport"
|
||||
import { useGraphData } from "../hooks/use-graph-data"
|
||||
import { useGraphTheme } from "../hooks/use-graph-theme"
|
||||
import type { GraphThemeColors, MemoryGraphProps } from "../types"
|
||||
import type {
|
||||
GraphApiDocument,
|
||||
GraphThemeColors,
|
||||
MemoryGraphProps,
|
||||
} from "../types"
|
||||
import { GraphCanvas } from "./graph-canvas"
|
||||
import { Legend } from "./legend"
|
||||
import { LoadingIndicator } from "./loading-indicator"
|
||||
|
|
@ -60,20 +64,41 @@ export function MemoryGraph({
|
|||
const limitedDocuments = useMemo(() => {
|
||||
if (!maxNodes || documents.length === 0) return documents
|
||||
let totalNodes = 0
|
||||
let cutoff = documents.length
|
||||
const limited: GraphApiDocument[] = []
|
||||
for (let i = 0; i < documents.length; i++) {
|
||||
const docNodes = 1 + (documents[i]?.memories?.length ?? 0)
|
||||
if (totalNodes + docNodes > maxNodes) {
|
||||
cutoff = i
|
||||
const doc = documents[i]
|
||||
if (!doc) continue
|
||||
if (totalNodes >= maxNodes) break
|
||||
|
||||
const remainingNodes = maxNodes - totalNodes
|
||||
const memories = doc.memories ?? []
|
||||
const docNodes = 1 + memories.length
|
||||
|
||||
if (docNodes <= remainingNodes) {
|
||||
limited.push(doc)
|
||||
totalNodes += docNodes
|
||||
continue
|
||||
}
|
||||
|
||||
if (remainingNodes > 1) {
|
||||
limited.push({
|
||||
...doc,
|
||||
memories: memories.slice(0, remainingNodes - 1),
|
||||
})
|
||||
totalNodes = maxNodes
|
||||
break
|
||||
}
|
||||
totalNodes += docNodes
|
||||
|
||||
limited.push({ ...doc, memories: [] })
|
||||
totalNodes += 1
|
||||
}
|
||||
return cutoff === documents.length ? documents : documents.slice(0, cutoff)
|
||||
return limited
|
||||
}, [documents, maxNodes])
|
||||
|
||||
const hasContainerSize = containerSize.width > 0 && containerSize.height > 0
|
||||
|
||||
const { nodes, edges } = useGraphData(
|
||||
limitedDocuments,
|
||||
hasContainerSize ? limitedDocuments : [],
|
||||
null,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
|
|
@ -149,17 +174,19 @@ export function MemoryGraph({
|
|||
// Auto-fit when data first loads. Mobile needs a few passes because the
|
||||
// force simulation can move nodes after the first layout frame.
|
||||
const hasAutoFittedRef = useRef(false)
|
||||
const hadValidContainerSizeRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasAutoFittedRef.current &&
|
||||
nodes.length > 0 &&
|
||||
viewportRef.current &&
|
||||
containerSize.width > 0
|
||||
hasContainerSize
|
||||
) {
|
||||
const fitDelays = isCompactViewport ? [100, 450, 900] : [100]
|
||||
const fitDelays = isCompactViewport ? [100, 450, 900] : [100, 300]
|
||||
const timers = fitDelays.map((delay, index) =>
|
||||
setTimeout(() => {
|
||||
viewportRef.current?.fitToNodes(
|
||||
if (!viewportRef.current || !hasContainerSize) return
|
||||
viewportRef.current.fitToNodes(
|
||||
nodes,
|
||||
containerSize.width,
|
||||
graphFitHeight,
|
||||
|
|
@ -173,10 +200,17 @@ export function MemoryGraph({
|
|||
for (const timer of timers) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [nodes, containerSize.width, graphFitHeight, isCompactViewport])
|
||||
}, [
|
||||
nodes,
|
||||
containerSize.width,
|
||||
graphFitHeight,
|
||||
isCompactViewport,
|
||||
hasContainerSize,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCompactViewport || nodes.length === 0 || !viewportRef.current) return
|
||||
if (!hasContainerSize) return
|
||||
const timer = setTimeout(() => {
|
||||
viewportRef.current?.fitToNodes(
|
||||
nodes,
|
||||
|
|
@ -185,7 +219,13 @@ export function MemoryGraph({
|
|||
)
|
||||
}, 120)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isCompactViewport, nodes, containerSize.width, graphFitHeight])
|
||||
}, [
|
||||
isCompactViewport,
|
||||
nodes,
|
||||
containerSize.width,
|
||||
graphFitHeight,
|
||||
hasContainerSize,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) hasAutoFittedRef.current = false
|
||||
|
|
@ -197,20 +237,39 @@ export function MemoryGraph({
|
|||
}
|
||||
}, [isCompactViewport])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasContainerSize && !hadValidContainerSizeRef.current) {
|
||||
hadValidContainerSizeRef.current = true
|
||||
hasAutoFittedRef.current = false
|
||||
}
|
||||
if (!hasContainerSize) {
|
||||
hadValidContainerSizeRef.current = false
|
||||
}
|
||||
}, [hasContainerSize])
|
||||
|
||||
// Container resize observer
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
setContainerBounds(el.getBoundingClientRect())
|
||||
})
|
||||
ro.observe(el)
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
setContainerBounds(el.getBoundingClientRect())
|
||||
const measure = () => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const width = Math.round(rect.width) || el.clientWidth
|
||||
const height = Math.round(rect.height) || el.clientHeight
|
||||
setContainerSize({ width, height })
|
||||
setContainerBounds(rect)
|
||||
}
|
||||
|
||||
return () => ro.disconnect()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
const parent = el.parentElement
|
||||
if (parent) ro.observe(parent)
|
||||
measure()
|
||||
const raf = requestAnimationFrame(measure)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Callbacks for GraphCanvas
|
||||
|
|
@ -600,7 +659,8 @@ export function MemoryGraph({
|
|||
return chainIndex.current.getChain(activeNodeData.id)
|
||||
}, [activeNodeData, limitedDocuments])
|
||||
|
||||
const isLoading = externalIsLoading
|
||||
const isLayoutPending = !hasContainerSize && limitedDocuments.length > 0
|
||||
const isLoading = externalIsLoading || isLayoutPending
|
||||
|
||||
if (externalError) {
|
||||
const errorContainerStyle: React.CSSProperties = {
|
||||
|
|
@ -682,7 +742,7 @@ export function MemoryGraph({
|
|||
)}
|
||||
|
||||
<div style={canvasContainerStyle} ref={containerRef}>
|
||||
{containerSize.width > 0 && containerSize.height > 0 && (
|
||||
{hasContainerSize && (
|
||||
<GraphCanvas
|
||||
colors={colors}
|
||||
edges={edges}
|
||||
|
|
|
|||
|
|
@ -292,18 +292,22 @@ export function getNodeBounds(nodes: GraphNode[]) {
|
|||
}
|
||||
}
|
||||
|
||||
type NodeBounds = NonNullable<ReturnType<typeof getNodeBounds>>
|
||||
|
||||
export function getAppendPosition(
|
||||
existingNodes: GraphNode[],
|
||||
appendIndex: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
baseBounds?: NodeBounds | null,
|
||||
spatialGrid?: Map<string, GraphNode[]>,
|
||||
) {
|
||||
const bounds = getNodeBounds(existingNodes)
|
||||
const bounds = baseBounds ?? getNodeBounds(existingNodes)
|
||||
if (!bounds) {
|
||||
return { x: canvasWidth / 2, y: canvasHeight / 2 }
|
||||
}
|
||||
|
||||
const spatialGrid = buildAppendSpatialGrid(existingNodes)
|
||||
const candidateGrid = spatialGrid ?? buildAppendSpatialGrid(existingNodes)
|
||||
const boundsWidth = bounds.maxX - bounds.minX
|
||||
const boundsHeight = bounds.maxY - bounds.minY
|
||||
const baseRadiusX = boundsWidth / 2 + APPEND_CLUSTER_RADIUS + APPEND_AREA_GAP
|
||||
|
|
@ -323,7 +327,7 @@ export function getAppendPosition(
|
|||
y: bounds.centerY + Math.sin(angle) * radiusY,
|
||||
}
|
||||
|
||||
if (isAppendCandidateOpen(candidate, spatialGrid)) {
|
||||
if (isAppendCandidateOpen(candidate, candidateGrid)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
|
@ -366,20 +370,27 @@ function isAppendCandidateOpen(
|
|||
function buildAppendSpatialGrid(nodes: GraphNode[]): Map<string, GraphNode[]> {
|
||||
const grid = new Map<string, GraphNode[]>()
|
||||
for (const node of nodes) {
|
||||
const key = getAppendSpatialKey(
|
||||
getAppendSpatialCell(node.x),
|
||||
getAppendSpatialCell(node.y),
|
||||
)
|
||||
const bucket = grid.get(key)
|
||||
if (bucket) {
|
||||
bucket.push(node)
|
||||
} else {
|
||||
grid.set(key, [node])
|
||||
}
|
||||
addAppendSpatialNode(grid, node)
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
function addAppendSpatialNode(
|
||||
grid: Map<string, GraphNode[]>,
|
||||
node: GraphNode,
|
||||
): void {
|
||||
const key = getAppendSpatialKey(
|
||||
getAppendSpatialCell(node.x),
|
||||
getAppendSpatialCell(node.y),
|
||||
)
|
||||
const bucket = grid.get(key)
|
||||
if (bucket) {
|
||||
bucket.push(node)
|
||||
} else {
|
||||
grid.set(key, [node])
|
||||
}
|
||||
}
|
||||
|
||||
function getAppendSpatialCell(value: number): number {
|
||||
return Math.floor(value / APPEND_SPATIAL_CELL_SIZE)
|
||||
}
|
||||
|
|
@ -472,6 +483,14 @@ export function useGraphData(
|
|||
const appendPlacementNodes = Array.from(previousCache.values()).filter(
|
||||
(node) => currentIds.has(node.id),
|
||||
)
|
||||
const shouldAppendNewNodes = appendPlacementNodes.length > 0
|
||||
const appendBaseBounds = shouldAppendNewNodes
|
||||
? getNodeBounds(appendPlacementNodes)
|
||||
: null
|
||||
const appendSpatialGrid =
|
||||
shouldAppendNewNodes && appendBaseBounds
|
||||
? buildAppendSpatialGrid(appendPlacementNodes)
|
||||
: null
|
||||
let appendIndex = 0
|
||||
const clusterAssignments = computeClusterAssignments(documents)
|
||||
|
||||
|
|
@ -519,12 +538,14 @@ export function useGraphData(
|
|||
}
|
||||
} else {
|
||||
const appendPosition =
|
||||
appendPlacementNodes.length > 0
|
||||
shouldAppendNewNodes && appendBaseBounds && appendSpatialGrid
|
||||
? getAppendPosition(
|
||||
appendPlacementNodes,
|
||||
appendIndex++,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
appendBaseBounds,
|
||||
appendSpatialGrid,
|
||||
)
|
||||
: null
|
||||
|
||||
|
|
@ -541,7 +562,10 @@ export function useGraphData(
|
|||
isHovered: false,
|
||||
isDragging: false,
|
||||
}
|
||||
appendPlacementNodes.push(docNode)
|
||||
if (appendSpatialGrid) {
|
||||
appendPlacementNodes.push(docNode)
|
||||
addAppendSpatialNode(appendSpatialGrid, docNode)
|
||||
}
|
||||
}
|
||||
nextCache.set(doc.id, docNode)
|
||||
result.push(docNode)
|
||||
|
|
@ -583,7 +607,10 @@ export function useGraphData(
|
|||
isHovered: false,
|
||||
isDragging: false,
|
||||
}
|
||||
appendPlacementNodes.push(memNode)
|
||||
if (appendSpatialGrid) {
|
||||
appendPlacementNodes.push(memNode)
|
||||
addAppendSpatialNode(appendSpatialGrid, memNode)
|
||||
}
|
||||
}
|
||||
nextCache.set(mem.id, memNode)
|
||||
result.push(memNode)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue