mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-04 22:50:08 +00:00
Rewrite @supermemory/memory-graph with perf optimizations + consolidate consumers (#809)
Co-authored-by: Vorflux AI <noreply@vorflux.com>
This commit is contained in:
parent
38282a37d6
commit
851b8cfe86
92 changed files with 6791 additions and 12118 deletions
|
|
@ -1,273 +1,154 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useCallback, useMemo, useState, useRef, useEffect } from "react"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { useMemo } from "react"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type {
|
||||
GraphViewportResponse,
|
||||
GraphBoundsResponse,
|
||||
GraphStatsResponse,
|
||||
} from "../types"
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
MemoryRelation,
|
||||
} from "@supermemory/memory-graph"
|
||||
|
||||
interface ViewportParams {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
interface UseGraphApiOptions {
|
||||
containerTags?: string[]
|
||||
limit?: number
|
||||
enabled?: boolean
|
||||
documentIds?: string[]
|
||||
}
|
||||
|
||||
interface ApiMemoryEntry {
|
||||
id: string
|
||||
memory: string
|
||||
content?: string | null
|
||||
spaceId: string
|
||||
isStatic?: boolean
|
||||
isLatest?: boolean
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: string | null
|
||||
forgetReason?: string | null
|
||||
version?: number
|
||||
parentMemoryId?: string | null
|
||||
rootMemoryId?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
relation?: MemoryRelation | null
|
||||
updatesMemoryId?: string | null
|
||||
nextVersionId?: string | null
|
||||
memoryRelations?: Record<string, MemoryRelation> | null
|
||||
spaceContainerTag?: string | null
|
||||
}
|
||||
|
||||
interface ApiDocument {
|
||||
id: string
|
||||
title: string | null
|
||||
summary?: string | null
|
||||
type: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memoryEntries: ApiMemoryEntry[]
|
||||
}
|
||||
|
||||
interface ApiDocumentsResponse {
|
||||
documents: ApiDocument[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
|
||||
return {
|
||||
id: mem.id,
|
||||
memory: mem.memory ?? mem.content ?? "",
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? "",
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
relation: mem.relation ?? null,
|
||||
updatesMemoryId: mem.updatesMemoryId ?? null,
|
||||
nextVersionId: mem.nextVersionId ?? null,
|
||||
memoryRelations: mem.memoryRelations ?? null,
|
||||
spaceContainerTag: mem.spaceContainerTag ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function toGraphDocument(doc: ApiDocument): GraphApiDocument {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.type,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memoryEntries.map(toGraphMemory),
|
||||
}
|
||||
}
|
||||
|
||||
export function useGraphApi(options: UseGraphApiOptions = {}) {
|
||||
const { containerTags, documentIds, limit = 200, enabled = true } = options
|
||||
const { containerTags, enabled = true } = options
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [viewport, setViewport] = useState<ViewportParams>({
|
||||
minX: 0,
|
||||
maxX: 1000,
|
||||
minY: 0,
|
||||
maxY: 1000,
|
||||
})
|
||||
|
||||
// Debounce viewport changes
|
||||
const viewportTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const pendingViewportRef = useRef<ViewportParams | null>(null)
|
||||
|
||||
const updateViewport = useCallback((newViewport: ViewportParams) => {
|
||||
pendingViewportRef.current = newViewport
|
||||
|
||||
if (viewportTimeoutRef.current) {
|
||||
clearTimeout(viewportTimeoutRef.current)
|
||||
}
|
||||
|
||||
viewportTimeoutRef.current = setTimeout(() => {
|
||||
if (pendingViewportRef.current) {
|
||||
setViewport(pendingViewportRef.current)
|
||||
pendingViewportRef.current = null
|
||||
}
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (viewportTimeoutRef.current) {
|
||||
clearTimeout(viewportTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const boundsQuery = useQuery({
|
||||
queryKey: ["graph-bounds", containerTags?.join(",")],
|
||||
queryFn: async (): Promise<GraphBoundsResponse> => {
|
||||
const params = new URLSearchParams()
|
||||
if (containerTags?.length) {
|
||||
params.set("containerTags", JSON.stringify(containerTags))
|
||||
}
|
||||
|
||||
const response = await $fetch("@get/graph/bounds", {
|
||||
query: Object.fromEntries(params),
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph bounds",
|
||||
)
|
||||
}
|
||||
|
||||
return response.data as GraphBoundsResponse
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["graph-stats", containerTags?.join(",")],
|
||||
queryFn: async (): Promise<GraphStatsResponse> => {
|
||||
const params = new URLSearchParams()
|
||||
if (containerTags?.length) {
|
||||
params.set("containerTags", JSON.stringify(containerTags))
|
||||
}
|
||||
|
||||
const response = await $fetch("@get/graph/stats", {
|
||||
query: Object.fromEntries(params),
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph stats",
|
||||
)
|
||||
}
|
||||
|
||||
return response.data as GraphStatsResponse
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const viewportQuery = useQuery({
|
||||
queryKey: [
|
||||
"graph-viewport",
|
||||
viewport.minX,
|
||||
viewport.maxX,
|
||||
viewport.minY,
|
||||
viewport.maxY,
|
||||
containerTags?.join(","),
|
||||
documentIds?.join(","),
|
||||
limit,
|
||||
],
|
||||
queryFn: async (): Promise<GraphViewportResponse> => {
|
||||
const response = await $fetch("@post/graph/viewport", {
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery<ApiDocumentsResponse, Error>({
|
||||
queryKey: ["graph-documents", containerTags?.join(",")],
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
viewport: {
|
||||
minX: viewport.minX,
|
||||
maxX: viewport.maxX,
|
||||
minY: viewport.minY,
|
||||
maxY: viewport.maxY,
|
||||
},
|
||||
page: pageParam as number,
|
||||
limit: PAGE_SIZE,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags,
|
||||
documentIds,
|
||||
limit,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph viewport",
|
||||
)
|
||||
throw new Error(response.error?.message || "Failed to fetch documents")
|
||||
}
|
||||
|
||||
return response.data as GraphViewportResponse
|
||||
return response.data as unknown as ApiDocumentsResponse
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const { currentPage, totalPages } = lastPage.pagination
|
||||
if (currentPage < totalPages) {
|
||||
return currentPage + 1
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
// Prefetch adjacent viewports for smoother panning
|
||||
const prefetchAdjacentViewports = useCallback(
|
||||
(currentViewport: ViewportParams) => {
|
||||
const viewportWidth = currentViewport.maxX - currentViewport.minX
|
||||
const viewportHeight = currentViewport.maxY - currentViewport.minY
|
||||
const documents = useMemo(() => {
|
||||
if (!data?.pages) return []
|
||||
return data.pages.flatMap((page) => page.documents.map(toGraphDocument))
|
||||
}, [data])
|
||||
|
||||
const offsets = [
|
||||
{ dx: viewportWidth * 0.5, dy: 0 },
|
||||
{ dx: -viewportWidth * 0.5, dy: 0 },
|
||||
{ dx: 0, dy: viewportHeight * 0.5 },
|
||||
{ dx: 0, dy: -viewportHeight * 0.5 },
|
||||
]
|
||||
|
||||
offsets.forEach(({ dx, dy }) => {
|
||||
const prefetchViewport = {
|
||||
minX: Math.max(0, currentViewport.minX + dx),
|
||||
maxX: Math.max(0, currentViewport.maxX + dx),
|
||||
minY: Math.max(0, currentViewport.minY + dy),
|
||||
maxY: Math.max(0, currentViewport.maxY + dy),
|
||||
}
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: [
|
||||
"graph-viewport",
|
||||
prefetchViewport.minX,
|
||||
prefetchViewport.maxX,
|
||||
prefetchViewport.minY,
|
||||
prefetchViewport.maxY,
|
||||
containerTags?.join(","),
|
||||
limit,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/graph/viewport", {
|
||||
body: {
|
||||
viewport: prefetchViewport,
|
||||
containerTags,
|
||||
limit,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph viewport",
|
||||
)
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
})
|
||||
},
|
||||
[queryClient, containerTags, limit],
|
||||
)
|
||||
|
||||
const data = useMemo(() => {
|
||||
return {
|
||||
documents: viewportQuery.data?.documents ?? [],
|
||||
edges: viewportQuery.data?.edges ?? [],
|
||||
totalCount: viewportQuery.data?.totalCount ?? 0,
|
||||
bounds: boundsQuery.data?.bounds ?? null,
|
||||
stats: statsQuery.data ?? null,
|
||||
}
|
||||
}, [viewportQuery.data, boundsQuery.data, statsQuery.data])
|
||||
|
||||
const isLoading = viewportQuery.isPending || boundsQuery.isPending
|
||||
const isRefetching = viewportQuery.isRefetching
|
||||
const error =
|
||||
viewportQuery.error || boundsQuery.error || statsQuery.error || null
|
||||
const totalCount = data?.pages[0]?.pagination.totalItems ?? 0
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
error,
|
||||
viewport,
|
||||
updateViewport,
|
||||
prefetchAdjacentViewports,
|
||||
refetch: viewportQuery.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales backend coordinates (0-1000) to graph canvas coordinates
|
||||
*/
|
||||
export function scaleBackendToCanvas(
|
||||
x: number,
|
||||
y: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): { x: number; y: number } {
|
||||
const scale = Math.min(canvasWidth, canvasHeight) / 1000
|
||||
const offsetX = (canvasWidth - 1000 * scale) / 2
|
||||
const offsetY = (canvasHeight - 1000 * scale) / 2
|
||||
|
||||
return {
|
||||
x: x * scale + offsetX,
|
||||
y: y * scale + offsetY,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales canvas coordinates to backend coordinates (0-1000)
|
||||
*/
|
||||
export function scaleCanvasToBackend(
|
||||
x: number,
|
||||
y: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): { x: number; y: number } {
|
||||
const scale = Math.min(canvasWidth, canvasHeight) / 1000
|
||||
const offsetX = (canvasWidth - 1000 * scale) / 2
|
||||
const offsetY = (canvasHeight - 1000 * scale) / 2
|
||||
|
||||
return {
|
||||
x: (x - offsetX) / scale,
|
||||
y: (y - offsetY) / scale,
|
||||
documents,
|
||||
isLoading: isPending,
|
||||
isLoadingMore: isFetchingNextPage,
|
||||
error: error ?? null,
|
||||
hasMore: hasNextPage ?? false,
|
||||
loadMore: fetchNextPage,
|
||||
totalCount,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue