mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
refactor: simplify graph API - remove backend computation, use client-side layout
- Remove x/y from GraphApiDocument (client computes via force simulation) - Remove similarity from GraphApiEdge, add edgeType enum - Replace viewport/bounds/stats API calls with single paginated documents endpoint - Derive edges client-side from document structure (doc-memory, version, same-space) - Update MCP client/server to use documents endpoint - Update MCP UI app for new type shapes - Update all tests for new API contract - Progressive loading via useInfiniteQuery (100 docs/page)
This commit is contained in:
parent
76f5eaa446
commit
429c7e97ad
23 changed files with 384 additions and 866 deletions
|
|
@ -45,52 +45,41 @@ export interface Project {
|
|||
documentCount?: number
|
||||
}
|
||||
|
||||
// Graph API types
|
||||
export interface GraphApiMemory {
|
||||
// Documents API types
|
||||
export interface DocumentMemoryEntry {
|
||||
id: string
|
||||
memory: string
|
||||
isStatic: boolean
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
forgetAfter: string | null
|
||||
version: number
|
||||
parentMemoryId: 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
|
||||
}
|
||||
|
||||
export interface GraphApiDocument {
|
||||
export interface DocumentWithMemories {
|
||||
id: string
|
||||
title: string | null
|
||||
summary: string | null
|
||||
documentType: string
|
||||
summary?: string | null
|
||||
type: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
x: number
|
||||
y: number
|
||||
memories: GraphApiMemory[]
|
||||
memoryEntries: DocumentMemoryEntry[]
|
||||
}
|
||||
|
||||
export interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
export interface GraphViewportResponse {
|
||||
documents: GraphApiDocument[]
|
||||
edges: GraphApiEdge[]
|
||||
viewport: { minX: number; maxX: number; minY: number; maxY: number }
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export interface GraphBoundsResponse {
|
||||
bounds: {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
} | null
|
||||
export interface DocumentsApiResponse {
|
||||
documents: DocumentWithMemories[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export function getMemoryText(m: Memory): string {
|
||||
|
|
@ -332,53 +321,33 @@ export class SupermemoryClient {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch graph bounds for coordinate range
|
||||
async getGraphBounds(containerTags?: string[]): Promise<GraphBoundsResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (containerTags?.length) {
|
||||
params.set("containerTags", JSON.stringify(containerTags))
|
||||
}
|
||||
const url = `${this.apiUrl}/v3/graph/bounds${params.toString() ? `?${params}` : ""}`
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bearerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch graph bounds"), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return (await response.json()) as GraphBoundsResponse
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch graph data for a viewport region
|
||||
async getGraphViewport(
|
||||
viewport: { minX: number; maxX: number; minY: number; maxY: number },
|
||||
// Fetch documents with their memory entries
|
||||
async getDocuments(
|
||||
containerTags?: string[],
|
||||
page = 1,
|
||||
limit = 200,
|
||||
): Promise<GraphViewportResponse> {
|
||||
): Promise<DocumentsApiResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/v3/graph/viewport`, {
|
||||
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bearerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ viewport, containerTags, limit }),
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
limit,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch graph viewport"), {
|
||||
throw Object.assign(new Error("Failed to fetch documents"), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return (await response.json()) as GraphViewportResponse
|
||||
return (await response.json()) as DocumentsApiResponse
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,21 +311,14 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
? [effectiveContainerTag]
|
||||
: undefined
|
||||
|
||||
const [bounds, viewport] = await Promise.all([
|
||||
client.getGraphBounds(containerTags),
|
||||
client.getGraphViewport(
|
||||
{ minX: 0, maxX: 1000, minY: 0, maxY: 1000 },
|
||||
containerTags,
|
||||
200,
|
||||
),
|
||||
])
|
||||
const result = await client.getDocuments(containerTags, 1, 200)
|
||||
|
||||
const memoryCount = viewport.documents.reduce(
|
||||
(sum, d) => sum + d.memories.length,
|
||||
const memoryCount = result.documents.reduce(
|
||||
(sum, d) => sum + d.memoryEntries.length,
|
||||
0,
|
||||
)
|
||||
const textParts = [
|
||||
`Memory Graph: ${viewport.documents.length} documents, ${memoryCount} memories, ${viewport.edges.length} connections`,
|
||||
`Memory Graph: ${result.documents.length} documents, ${memoryCount} memories`,
|
||||
]
|
||||
if (effectiveContainerTag) {
|
||||
textParts.push(`Project: ${effectiveContainerTag}`)
|
||||
|
|
@ -335,10 +328,8 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
content: [{ type: "text" as const, text: textParts.join(". ") }],
|
||||
structuredContent: {
|
||||
containerTag: effectiveContainerTag,
|
||||
bounds: bounds.bounds,
|
||||
documents: viewport.documents,
|
||||
edges: viewport.edges,
|
||||
totalCount: viewport.totalCount,
|
||||
documents: result.documents,
|
||||
totalCount: result.pagination.totalItems,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -359,20 +350,15 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
},
|
||||
)
|
||||
|
||||
// App-only tool for the UI to fetch additional graph data
|
||||
// App-only tool for the UI to fetch additional documents (pagination)
|
||||
registerAppTool(
|
||||
this.server,
|
||||
"fetch-graph-data",
|
||||
{
|
||||
description: "Fetch graph data for a viewport region",
|
||||
description: "Fetch documents with memories for graph display",
|
||||
inputSchema: z.object({
|
||||
containerTag: z.string().optional(),
|
||||
viewport: z.object({
|
||||
minX: z.number(),
|
||||
maxX: z.number(),
|
||||
minY: z.number(),
|
||||
maxY: z.number(),
|
||||
}),
|
||||
page: z.number().optional().default(1),
|
||||
limit: z.number().optional().default(200),
|
||||
}),
|
||||
_meta: {
|
||||
|
|
@ -385,12 +371,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
async (args: {
|
||||
containerTag?: string
|
||||
viewport: {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
page?: number
|
||||
limit?: number
|
||||
}) => {
|
||||
try {
|
||||
|
|
@ -400,9 +381,9 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
const containerTags = effectiveContainerTag
|
||||
? [effectiveContainerTag]
|
||||
: undefined
|
||||
const data = await client.getGraphViewport(
|
||||
args.viewport,
|
||||
const data = await client.getDocuments(
|
||||
containerTags,
|
||||
args.page,
|
||||
args.limit,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,11 +27,14 @@ interface GraphApiMemory {
|
|||
id: string
|
||||
memory: string
|
||||
isStatic: boolean
|
||||
spaceId: string
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
forgetAfter: string | null
|
||||
forgetReason: string | null
|
||||
version: number
|
||||
parentMemoryId: string | null
|
||||
rootMemoryId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
|
@ -43,22 +46,12 @@ interface GraphApiDocument {
|
|||
documentType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
x: number
|
||||
y: number
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface ToolResultData {
|
||||
containerTag?: string
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number } | null
|
||||
documents: GraphApiDocument[]
|
||||
edges: GraphApiEdge[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
|
|
@ -91,8 +84,7 @@ type GraphNode = MemoryNode | DocumentNode
|
|||
interface GraphLink extends LinkObject {
|
||||
source: string | GraphNode
|
||||
target: string | GraphNode
|
||||
edgeType: "doc-memory" | "version" | "similarity"
|
||||
similarity?: number
|
||||
edgeType: "doc-memory" | "version" | "same-space"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -109,12 +101,12 @@ const EDGE_COLORS = {
|
|||
dark: {
|
||||
"doc-memory": "#4A5568",
|
||||
version: "#8B5CF6",
|
||||
similarity: "#00D4B8",
|
||||
"same-space": "#00D4B8",
|
||||
},
|
||||
light: {
|
||||
"doc-memory": "#A0AEC0",
|
||||
version: "#8B5CF6",
|
||||
similarity: "#0D9488",
|
||||
"same-space": "#0D9488",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -157,33 +149,20 @@ function getMemoryBorderColor(mem: GraphApiMemory): string {
|
|||
return MEMORY_BORDER.default
|
||||
}
|
||||
|
||||
function normalizeDocCoordinates(
|
||||
documents: GraphApiDocument[],
|
||||
): GraphApiDocument[] {
|
||||
if (documents.length <= 1) return documents
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
for (const doc of documents) {
|
||||
minX = Math.min(minX, doc.x)
|
||||
maxX = Math.max(maxX, doc.x)
|
||||
minY = Math.min(minY, doc.y)
|
||||
maxY = Math.max(maxY, doc.y)
|
||||
/** Simple hash to get deterministic initial positions from doc ID */
|
||||
function hashCode(s: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
const rangeX = maxX - minX || 1
|
||||
const rangeY = maxY - minY || 1
|
||||
// Small spread so documents start near each other.
|
||||
// The force simulation will naturally separate them.
|
||||
const SPREAD = 50
|
||||
|
||||
return documents.map((doc) => ({
|
||||
...doc,
|
||||
x: ((doc.x - minX) / rangeX - 0.5) * SPREAD,
|
||||
y: ((doc.y - minY) / rangeY - 0.5) * SPREAD,
|
||||
}))
|
||||
function initialPosition(id: string, spread: number): { x: number; y: number } {
|
||||
const h = hashCode(id)
|
||||
const angle = ((h & 0xffff) / 0xffff) * Math.PI * 2
|
||||
const radius = (((h >>> 16) & 0xffff) / 0xffff) * spread
|
||||
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }
|
||||
}
|
||||
|
||||
function transformData(data: ToolResultData): {
|
||||
|
|
@ -193,10 +172,13 @@ function transformData(data: ToolResultData): {
|
|||
const nodes: GraphNode[] = []
|
||||
const links: GraphLink[] = []
|
||||
const nodeIds = new Set<string>()
|
||||
const SPREAD = 50
|
||||
|
||||
const normalizedDocs = normalizeDocCoordinates(data.documents)
|
||||
// Group documents by spaceId for same-space edges
|
||||
const spaceGroups = new Map<string, string[]>()
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
for (const doc of data.documents) {
|
||||
const pos = initialPosition(doc.id, SPREAD)
|
||||
nodes.push({
|
||||
id: doc.id,
|
||||
nodeType: "document",
|
||||
|
|
@ -205,8 +187,8 @@ function transformData(data: ToolResultData): {
|
|||
docType: doc.documentType,
|
||||
createdAt: doc.createdAt,
|
||||
memoryCount: doc.memories.length,
|
||||
x: doc.x,
|
||||
y: doc.y,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
} as DocumentNode)
|
||||
nodeIds.add(doc.id)
|
||||
|
||||
|
|
@ -227,8 +209,8 @@ function transformData(data: ToolResultData): {
|
|||
parentMemoryId: mem.parentMemoryId,
|
||||
createdAt: mem.createdAt,
|
||||
borderColor: getMemoryBorderColor(mem),
|
||||
x: doc.x + Math.cos(angle) * CLUSTER_SPREAD,
|
||||
y: doc.y + Math.sin(angle) * CLUSTER_SPREAD,
|
||||
x: pos.x + Math.cos(angle) * CLUSTER_SPREAD,
|
||||
y: pos.y + Math.sin(angle) * CLUSTER_SPREAD,
|
||||
} as MemoryNode)
|
||||
nodeIds.add(mem.id)
|
||||
|
||||
|
|
@ -243,18 +225,32 @@ function transformData(data: ToolResultData): {
|
|||
edgeType: "version",
|
||||
})
|
||||
}
|
||||
|
||||
// Track space groups for same-space edges
|
||||
if (mem.spaceId) {
|
||||
const group = spaceGroups.get(mem.spaceId)
|
||||
if (group) group.push(doc.id)
|
||||
else spaceGroups.set(mem.spaceId, [doc.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Similarity edges from API
|
||||
for (const edge of data.edges) {
|
||||
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
|
||||
links.push({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
edgeType: "similarity",
|
||||
similarity: edge.similarity,
|
||||
})
|
||||
// Same-space edges between documents sharing a space
|
||||
const addedEdges = new Set<string>()
|
||||
for (const docIds of spaceGroups.values()) {
|
||||
const unique = [...new Set(docIds)]
|
||||
for (let i = 0; i < unique.length; i++) {
|
||||
for (let j = i + 1; j < unique.length; j++) {
|
||||
const key = `${unique[i]}:${unique[j]}`
|
||||
if (!addedEdges.has(key)) {
|
||||
addedEdges.add(key)
|
||||
links.push({
|
||||
source: unique[i]!,
|
||||
target: unique[j]!,
|
||||
edgeType: "same-space",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -371,13 +367,12 @@ const graph = new ForceGraph<GraphNode, GraphLink>(container)
|
|||
)
|
||||
.linkWidth((link: GraphLink) => {
|
||||
if (link.edgeType === "version") return 2
|
||||
if (link.edgeType === "similarity")
|
||||
return 0.5 + (link.similarity || 0) * 1.5
|
||||
if (link.edgeType === "same-space") return 0.5
|
||||
return 1
|
||||
})
|
||||
.linkColor(getLinkColor)
|
||||
.linkLineDash((link: GraphLink) => {
|
||||
if (link.edgeType === "similarity") return [4, 2]
|
||||
if (link.edgeType === "same-space") return [4, 2]
|
||||
return null as unknown as number[]
|
||||
})
|
||||
.linkDirectionalArrowLength((link: GraphLink) =>
|
||||
|
|
@ -399,7 +394,7 @@ const graph = new ForceGraph<GraphNode, GraphLink>(container)
|
|||
.strength((l: GraphLink) => {
|
||||
if (l.edgeType === "doc-memory") return 0.8
|
||||
if (l.edgeType === "version") return 1.0
|
||||
return (l.similarity || 0.3) * 0.3
|
||||
return 0.15 // same-space
|
||||
}),
|
||||
)
|
||||
.d3Force("collide", forceCollide(18))
|
||||
|
|
|
|||
|
|
@ -21,13 +21,6 @@ interface DocumentsResponse {
|
|||
|
||||
/** Convert the external API format to the internal graph format */
|
||||
function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
|
||||
// Use a seeded random for deterministic positions
|
||||
let seed = 42
|
||||
const rand = () => {
|
||||
seed = (seed * 16807 + 0) % 2147483647
|
||||
return seed / 2147483647
|
||||
}
|
||||
|
||||
return docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
|
|
@ -35,8 +28,6 @@ function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
|
|||
documentType: doc.documentType,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
x: rand() * 1000,
|
||||
y: rand() * 1000,
|
||||
memories: doc.memories.map(
|
||||
(mem): GraphApiMemory => ({
|
||||
id: mem.id,
|
||||
|
|
@ -136,7 +127,6 @@ export default function Home() {
|
|||
const data = generateMockGraphData({
|
||||
documentCount: count,
|
||||
memoriesPerDoc: [2, 5],
|
||||
similarityEdgeRatio: 0.05,
|
||||
seed: 12345,
|
||||
})
|
||||
setMockData({ documents: data.documents })
|
||||
|
|
|
|||
|
|
@ -116,9 +116,8 @@ export const GraphCard = memo<GraphCardProps>(
|
|||
({ containerTags, width = 216, height = 220, className }) => {
|
||||
const { setViewMode } = useViewMode()
|
||||
|
||||
const { data, isLoading, error } = useGraphApi({
|
||||
const { documents, isLoading, error } = useGraphApi({
|
||||
containerTags,
|
||||
limit: 20,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
|
|
@ -139,11 +138,8 @@ export const GraphCard = memo<GraphCardProps>(
|
|||
)
|
||||
}
|
||||
|
||||
const documentCount = data.stats?.documentsWithSpatial ?? 0
|
||||
const memoryCount = data.documents.reduce(
|
||||
(sum, d) => sum + d.memories.length,
|
||||
0,
|
||||
)
|
||||
const documentCount = documents.length
|
||||
const memoryCount = documents.reduce((sum, d) => sum + d.memories.length, 0)
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,273 +1,142 @@
|
|||
"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,
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
} 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
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export type {
|
|||
GraphEdge,
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
GraphApiEdge,
|
||||
} from "@supermemory/memory-graph"
|
||||
|
||||
// Keep the API hook export
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
|
||||
import type { GraphApiDocument, GraphApiEdge } from "@supermemory/memory-graph"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
|
||||
export interface MemoryGraphWrapperProps {
|
||||
|
|
@ -28,7 +27,6 @@ export function MemoryGraph({
|
|||
error: externalError = null,
|
||||
variant = "console",
|
||||
containerTags,
|
||||
documentIds,
|
||||
maxNodes = 200,
|
||||
canvasRef,
|
||||
...rest
|
||||
|
|
@ -48,27 +46,31 @@ export function MemoryGraph({
|
|||
}, [])
|
||||
|
||||
const {
|
||||
data: apiData,
|
||||
documents,
|
||||
isLoading: apiIsLoading,
|
||||
isLoadingMore,
|
||||
error: apiError,
|
||||
hasMore,
|
||||
loadMore,
|
||||
totalCount,
|
||||
} = useGraphApi({
|
||||
containerTags,
|
||||
documentIds,
|
||||
limit: maxNodes,
|
||||
enabled: containerSize.width > 0 && containerSize.height > 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full">
|
||||
<MemoryGraphBase
|
||||
documents={apiData.documents as GraphApiDocument[]}
|
||||
apiEdges={apiData.edges as GraphApiEdge[]}
|
||||
documents={documents}
|
||||
isLoading={externalIsLoading || apiIsLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={hasMore ? () => loadMore() : undefined}
|
||||
hasMore={hasMore}
|
||||
error={externalError || apiError}
|
||||
variant={variant}
|
||||
maxNodes={maxNodes}
|
||||
canvasRef={canvasRef}
|
||||
totalCount={apiData.totalCount}
|
||||
totalCount={totalCount}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,10 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
normalizeDocCoordinates,
|
||||
getMemoryBorderColor,
|
||||
getEdgeVisualProps,
|
||||
screenToBackendCoords,
|
||||
calculateBackendViewport,
|
||||
} from "../hooks/use-graph-data"
|
||||
import { DEFAULT_COLORS } from "../constants"
|
||||
import type { GraphApiDocument, GraphApiMemory } from "../types"
|
||||
|
||||
function makeDoc(id: string, x: number, y: number): GraphApiDocument {
|
||||
return {
|
||||
id,
|
||||
title: `Doc ${id}`,
|
||||
summary: null,
|
||||
documentType: "text",
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
x,
|
||||
y,
|
||||
memories: [],
|
||||
}
|
||||
}
|
||||
import type { GraphApiMemory } from "../types"
|
||||
|
||||
function makeMemory(overrides: Partial<GraphApiMemory> = {}): GraphApiMemory {
|
||||
return {
|
||||
|
|
@ -42,59 +25,6 @@ function makeMemory(overrides: Partial<GraphApiMemory> = {}): GraphApiMemory {
|
|||
}
|
||||
}
|
||||
|
||||
describe("normalizeDocCoordinates", () => {
|
||||
it("normalizes coordinates to 100-900 range", () => {
|
||||
const docs = [
|
||||
makeDoc("a", 0, 0),
|
||||
makeDoc("b", 100, 100),
|
||||
makeDoc("c", 50, 50),
|
||||
]
|
||||
const result = normalizeDocCoordinates(docs)
|
||||
|
||||
for (const doc of result) {
|
||||
expect(doc.x).toBeGreaterThanOrEqual(100)
|
||||
expect(doc.x).toBeLessThanOrEqual(900)
|
||||
expect(doc.y).toBeGreaterThanOrEqual(100)
|
||||
expect(doc.y).toBeLessThanOrEqual(900)
|
||||
}
|
||||
})
|
||||
|
||||
it("maps min to 100 and max to 900", () => {
|
||||
const docs = [makeDoc("a", 0, 0), makeDoc("b", 100, 200)]
|
||||
const result = normalizeDocCoordinates(docs)
|
||||
|
||||
expect(result[0]!.x).toBeCloseTo(100)
|
||||
expect(result[0]!.y).toBeCloseTo(100)
|
||||
expect(result[1]!.x).toBeCloseTo(900)
|
||||
expect(result[1]!.y).toBeCloseTo(900)
|
||||
})
|
||||
|
||||
it("handles single document (returns as-is)", () => {
|
||||
const docs = [makeDoc("a", 500, 300)]
|
||||
const result = normalizeDocCoordinates(docs)
|
||||
expect(result).toEqual(docs)
|
||||
})
|
||||
|
||||
it("handles empty array", () => {
|
||||
const result = normalizeDocCoordinates([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("handles documents at same position", () => {
|
||||
const docs = [makeDoc("a", 50, 50), makeDoc("b", 50, 50)]
|
||||
// Should not throw (rangeX/rangeY fallback to 1)
|
||||
expect(() => normalizeDocCoordinates(docs)).not.toThrow()
|
||||
})
|
||||
|
||||
it("preserves document data (only x/y change)", () => {
|
||||
const docs = [makeDoc("a", 0, 0), makeDoc("b", 100, 100)]
|
||||
const result = normalizeDocCoordinates(docs)
|
||||
expect(result[0]!.id).toBe("a")
|
||||
expect(result[0]!.title).toBe("Doc a")
|
||||
expect(result[1]!.id).toBe("b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMemoryBorderColor", () => {
|
||||
const colors = DEFAULT_COLORS
|
||||
|
||||
|
|
@ -129,70 +59,27 @@ describe("getMemoryBorderColor", () => {
|
|||
})
|
||||
|
||||
describe("getEdgeVisualProps", () => {
|
||||
it("returns correct opacity and thickness for similarity 0", () => {
|
||||
const props = getEdgeVisualProps(0)
|
||||
it("returns correct props for doc-memory edges", () => {
|
||||
const props = getEdgeVisualProps("doc-memory")
|
||||
expect(props.opacity).toBeCloseTo(0.3)
|
||||
expect(props.thickness).toBeCloseTo(1.5)
|
||||
})
|
||||
|
||||
it("returns correct props for version edges", () => {
|
||||
const props = getEdgeVisualProps("version")
|
||||
expect(props.opacity).toBeCloseTo(0.6)
|
||||
expect(props.thickness).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it("returns correct props for same-space edges", () => {
|
||||
const props = getEdgeVisualProps("same-space")
|
||||
expect(props.opacity).toBeCloseTo(0.15)
|
||||
expect(props.thickness).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it("returns correct opacity and thickness for similarity 1", () => {
|
||||
const props = getEdgeVisualProps(1)
|
||||
expect(props.opacity).toBeCloseTo(0.8)
|
||||
expect(props.thickness).toBeCloseTo(2.5)
|
||||
})
|
||||
|
||||
it("returns intermediate values for similarity 0.5", () => {
|
||||
const props = getEdgeVisualProps(0.5)
|
||||
expect(props.opacity).toBeCloseTo(0.55)
|
||||
expect(props.thickness).toBeCloseTo(1.75)
|
||||
})
|
||||
})
|
||||
|
||||
describe("screenToBackendCoords", () => {
|
||||
it("converts screen coordinates to backend coordinates", () => {
|
||||
const result = screenToBackendCoords(400, 300, 0, 0, 1, 800, 600)
|
||||
expect(result.x).toBeDefined()
|
||||
expect(result.y).toBeDefined()
|
||||
})
|
||||
|
||||
it("accounts for pan offset", () => {
|
||||
const noPan = screenToBackendCoords(400, 300, 0, 0, 1, 800, 600)
|
||||
const withPan = screenToBackendCoords(400, 300, 100, 50, 1, 800, 600)
|
||||
// Panning right means the backend coordinate should be smaller
|
||||
expect(withPan.x).toBeLessThan(noPan.x)
|
||||
expect(withPan.y).toBeLessThan(noPan.y)
|
||||
})
|
||||
|
||||
it("accounts for zoom", () => {
|
||||
const zoom1 = screenToBackendCoords(400, 300, 0, 0, 1, 800, 600)
|
||||
const zoom2 = screenToBackendCoords(400, 300, 0, 0, 2, 800, 600)
|
||||
// At higher zoom, same screen position maps to smaller backend area
|
||||
expect(zoom2.x).toBeLessThan(zoom1.x)
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateBackendViewport", () => {
|
||||
it("returns min/max bounds", () => {
|
||||
const bounds = calculateBackendViewport(0, 0, 1, 800, 600)
|
||||
expect(bounds.minX).toBeDefined()
|
||||
expect(bounds.maxX).toBeDefined()
|
||||
expect(bounds.minY).toBeDefined()
|
||||
expect(bounds.maxY).toBeDefined()
|
||||
expect(bounds.maxX).toBeGreaterThan(bounds.minX)
|
||||
expect(bounds.maxY).toBeGreaterThan(bounds.minY)
|
||||
})
|
||||
|
||||
it("higher zoom produces smaller viewport", () => {
|
||||
const zoom1 = calculateBackendViewport(0, 0, 1, 800, 600)
|
||||
const zoom2 = calculateBackendViewport(0, 0, 2, 800, 600)
|
||||
const area1 = (zoom1.maxX - zoom1.minX) * (zoom1.maxY - zoom1.minY)
|
||||
const area2 = (zoom2.maxX - zoom2.minX) * (zoom2.maxY - zoom2.minY)
|
||||
expect(area2).toBeLessThan(area1)
|
||||
})
|
||||
|
||||
it("minX is non-negative", () => {
|
||||
const bounds = calculateBackendViewport(0, 0, 1, 800, 600)
|
||||
expect(bounds.minX).toBeGreaterThanOrEqual(0)
|
||||
expect(bounds.minY).toBeGreaterThanOrEqual(0)
|
||||
it("returns default props for unknown edge types", () => {
|
||||
const props = getEdgeVisualProps("unknown")
|
||||
expect(props.opacity).toBeCloseTo(0.3)
|
||||
expect(props.thickness).toBeCloseTo(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ describe("generateMockGraphData", () => {
|
|||
expect(data1.documents.length).toBe(data2.documents.length)
|
||||
expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id)
|
||||
expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title)
|
||||
expect(data1.documents[0]!.x).toBe(data2.documents[0]!.x)
|
||||
expect(data1.documents[0]!.y).toBe(data2.documents[0]!.y)
|
||||
})
|
||||
|
||||
it("produces different output with different seeds", () => {
|
||||
|
|
@ -37,8 +35,6 @@ describe("generateMockGraphData", () => {
|
|||
expect(doc.documentType).toBeDefined()
|
||||
expect(doc.createdAt).toBeDefined()
|
||||
expect(doc.updatedAt).toBeDefined()
|
||||
expect(typeof doc.x).toBe("number")
|
||||
expect(typeof doc.y).toBe("number")
|
||||
expect(Array.isArray(doc.memories)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
|
@ -60,33 +56,9 @@ describe("generateMockGraphData", () => {
|
|||
}
|
||||
})
|
||||
|
||||
it("generates edges", () => {
|
||||
const data = generateMockGraphData({
|
||||
documentCount: 20,
|
||||
similarityEdgeRatio: 0.1,
|
||||
seed: 1,
|
||||
})
|
||||
expect(data.edges.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("edges reference valid document IDs", () => {
|
||||
const data = generateMockGraphData({
|
||||
documentCount: 20,
|
||||
similarityEdgeRatio: 0.1,
|
||||
seed: 1,
|
||||
})
|
||||
const allDocIds = new Set(data.documents.map((d) => d.id))
|
||||
|
||||
for (const edge of data.edges) {
|
||||
expect(allDocIds.has(edge.source)).toBe(true)
|
||||
expect(allDocIds.has(edge.target)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("handles zero documents", () => {
|
||||
const data = generateMockGraphData({ documentCount: 0, seed: 1 })
|
||||
expect(data.documents.length).toBe(0)
|
||||
expect(data.edges.length).toBe(0)
|
||||
})
|
||||
|
||||
it("respects memoriesPerDoc range", () => {
|
||||
|
|
@ -99,4 +71,17 @@ describe("generateMockGraphData", () => {
|
|||
expect(doc.memories.length).toBe(3)
|
||||
}
|
||||
})
|
||||
|
||||
it("generates version chains for some documents", () => {
|
||||
const data = generateMockGraphData({
|
||||
documentCount: 50,
|
||||
memoriesPerDoc: [3, 6],
|
||||
seed: 42,
|
||||
})
|
||||
// With 50 docs and 30% chain probability, we should have some chains
|
||||
const hasChain = data.documents.some((doc) =>
|
||||
doc.memories.some((mem) => mem.parentMemoryId !== null),
|
||||
)
|
||||
expect(hasChain).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ function makeEdge(source: string, target: string): GraphEdge {
|
|||
id: `e-${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
similarity: 0.8,
|
||||
visualProps: { opacity: 0.5, thickness: 1.5 },
|
||||
edgeType: "doc-memory",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument {
|
|||
documentType: "text",
|
||||
createdAt: "2024-01-01",
|
||||
updatedAt: "2024-01-01",
|
||||
x: 0,
|
||||
y: 0,
|
||||
memories,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ function drawDocDocLines(
|
|||
}
|
||||
|
||||
// For each doc, find 2 nearest from neighboring cells only
|
||||
ctx.strokeStyle = colors.edgeDocDoc
|
||||
ctx.strokeStyle = colors.edgeSameSpace
|
||||
ctx.lineWidth = 1
|
||||
ctx.globalAlpha = 0.3
|
||||
ctx.setLineDash([4, 6])
|
||||
|
|
@ -135,9 +135,9 @@ function edgeStyle(
|
|||
return { color: colors.edgeDocMemory, width: 1.5 }
|
||||
if (edge.edgeType === "version")
|
||||
return { color: colors.edgeVersion, width: 2 }
|
||||
if (edge.similarity >= 0.9) return { color: colors.edgeSimStrong, width: 2 }
|
||||
if (edge.similarity >= 0.8) return { color: colors.edgeSimMedium, width: 1.5 }
|
||||
return { color: colors.edgeSimWeak, width: 1 }
|
||||
if (edge.edgeType === "same-space")
|
||||
return { color: colors.edgeSameSpace, width: 1 }
|
||||
return { color: colors.edgeSameSpace, width: 1 }
|
||||
}
|
||||
|
||||
function batchKey(style: { color: string; width: number }): string {
|
||||
|
|
@ -171,10 +171,9 @@ function drawEdges(
|
|||
const prepared: PreparedEdge[] = []
|
||||
|
||||
for (const edge of edges) {
|
||||
// Zoom-based edge culling for similarity edges
|
||||
if (edge.edgeType === "similarity") {
|
||||
// Zoom-based edge culling for same-space edges (low-priority visual)
|
||||
if (edge.edgeType === "same-space") {
|
||||
if (viewport.zoom < 0.3) continue
|
||||
if (viewport.zoom < 0.5 && edge.similarity < 0.9) continue
|
||||
}
|
||||
|
||||
const src =
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ export class ForceSimulation {
|
|||
.strength((link) => {
|
||||
if (link.edgeType === "doc-memory") return 0.8
|
||||
if (link.edgeType === "version") return 1.0
|
||||
return link.similarity * 0.3
|
||||
if (link.edgeType === "same-space") return 0.1
|
||||
return 0.3
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ export class VersionChainIndex {
|
|||
while (cursor) {
|
||||
const children = this.childrenMap.get(cursor.id)
|
||||
if (!children || children.length === 0) break
|
||||
const child = this.memoryMap.get(children[0])
|
||||
const firstChildId = children[0]
|
||||
if (!firstChildId) break
|
||||
const child = this.memoryMap.get(firstChildId)
|
||||
if (!child || visited.has(child.id)) break
|
||||
visited.add(child.id)
|
||||
forward.push(child)
|
||||
|
|
|
|||
|
|
@ -395,14 +395,8 @@ export const Legend = memo(function Legend({
|
|||
</div>
|
||||
<div style={rowStyle}>
|
||||
<div style={rowLeftStyle}>
|
||||
<LineIcon color={colors.edgeSimStrong} />
|
||||
<span style={edgeLabelStyle}>Similarity</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<div style={rowLeftStyle}>
|
||||
<LineIcon color={colors.edgeDocDoc} dashed />
|
||||
<span style={edgeLabelStyle}>Doc similarity</span>
|
||||
<LineIcon color={colors.edgeSameSpace} dashed />
|
||||
<span style={edgeLabelStyle}>Same space</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ import { NodeHoverPopover } from "./node-hover-popover"
|
|||
|
||||
export function MemoryGraph({
|
||||
documents = [],
|
||||
apiEdges = [],
|
||||
isLoading: externalIsLoading = false,
|
||||
isLoadingMore = false,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
error: externalError = null,
|
||||
children,
|
||||
variant = "console",
|
||||
|
|
@ -24,7 +26,6 @@ export function MemoryGraph({
|
|||
maxNodes,
|
||||
isSlideshowActive = false,
|
||||
onSlideshowNodeChange,
|
||||
onSlideshowStop: _onSlideshowStop,
|
||||
canvasRef: externalCanvasRef,
|
||||
colors: colorOverrides,
|
||||
totalCount,
|
||||
|
|
@ -58,7 +59,6 @@ export function MemoryGraph({
|
|||
|
||||
const { nodes, edges } = useGraphData(
|
||||
limitedDocuments,
|
||||
apiEdges,
|
||||
null,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
|
|
@ -545,11 +545,36 @@ export function MemoryGraph({
|
|||
<div style={wrapperStyle}>
|
||||
<LoadingIndicator
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={false}
|
||||
isLoadingMore={isLoadingMore}
|
||||
totalLoaded={totalCount ?? documents.length}
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
{!isLoading && hasMore && onLoadMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadMore}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 30,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
backgroundColor: colors.controlBg,
|
||||
color: colors.textSecondary,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{isLoadingMore ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isLoading && !nodes.some((n) => n.type === "document") && children && (
|
||||
<div style={emptyStateStyle}>{children}</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,7 @@ export const DEFAULT_COLORS: GraphThemeColors = {
|
|||
textMuted: "#94a3b8",
|
||||
edgeDocMemory: "#4A5568",
|
||||
edgeVersion: "#8B5CF6",
|
||||
edgeSimStrong: "#00D4B8",
|
||||
edgeSimMedium: "#6B8FBF",
|
||||
edgeSimWeak: "#4A6A8A",
|
||||
edgeDocDoc: "#8DA3F4",
|
||||
edgeSameSpace: "#4A6A8A",
|
||||
memBorderForgotten: "#EF4444",
|
||||
memBorderExpiring: "#F59E0B",
|
||||
memBorderRecent: "#10B981",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef } from "react"
|
|||
import type {
|
||||
DocumentNodeData,
|
||||
GraphApiDocument,
|
||||
GraphApiEdge,
|
||||
GraphApiMemory,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
|
|
@ -28,44 +27,34 @@ export function getMemoryBorderColor(
|
|||
return colors.memStrokeDefault
|
||||
}
|
||||
|
||||
export function getEdgeVisualProps(similarity: number) {
|
||||
return {
|
||||
opacity: 0.3 + similarity * 0.5,
|
||||
thickness: 1 + similarity * 1.5,
|
||||
export function getEdgeVisualProps(edgeType: string) {
|
||||
switch (edgeType) {
|
||||
case "doc-memory":
|
||||
return { opacity: 0.3, thickness: 1.5 }
|
||||
case "version":
|
||||
return { opacity: 0.6, thickness: 2 }
|
||||
case "same-space":
|
||||
return { opacity: 0.15, thickness: 1 }
|
||||
default:
|
||||
return { opacity: 0.3, thickness: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDocCoordinates(
|
||||
documents: GraphApiDocument[],
|
||||
): GraphApiDocument[] {
|
||||
if (documents.length <= 1) return documents
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const doc of documents) {
|
||||
minX = Math.min(minX, doc.x)
|
||||
maxX = Math.max(maxX, doc.x)
|
||||
minY = Math.min(minY, doc.y)
|
||||
maxY = Math.max(maxY, doc.y)
|
||||
/**
|
||||
* Simple deterministic hash of a string to a number in [0, 1).
|
||||
* Used for initial node placement so the force simulation has a
|
||||
* deterministic starting layout.
|
||||
*/
|
||||
function hashToUnit(str: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (Math.imul(31, h) + str.charCodeAt(i)) | 0
|
||||
}
|
||||
|
||||
const rangeX = maxX - minX || 1
|
||||
const rangeY = maxY - minY || 1
|
||||
const PAD = 100
|
||||
|
||||
return documents.map((doc) => ({
|
||||
...doc,
|
||||
x: PAD + ((doc.x - minX) / rangeX) * (1000 - 2 * PAD),
|
||||
y: PAD + ((doc.y - minY) / rangeY) * (1000 - 2 * PAD),
|
||||
}))
|
||||
return ((h >>> 0) % 10000) / 10000
|
||||
}
|
||||
|
||||
export function useGraphData(
|
||||
documents: GraphApiDocument[],
|
||||
apiEdges: GraphApiEdge[],
|
||||
draggingNodeId: string | null,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
|
|
@ -87,30 +76,20 @@ export function useGraphData(
|
|||
}
|
||||
}, [documents])
|
||||
|
||||
const { scale, offsetX, offsetY } = useMemo(() => {
|
||||
if (canvasWidth === 0 || canvasHeight === 0) {
|
||||
return { scale: 1, offsetX: 0, offsetY: 0 }
|
||||
}
|
||||
const paddingFactor = 0.8
|
||||
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
|
||||
const ox = (canvasWidth - 1000 * s) / 2
|
||||
const oy = (canvasHeight - 1000 * s) / 2
|
||||
return { scale: s, offsetX: ox, offsetY: oy }
|
||||
}, [canvasWidth, canvasHeight])
|
||||
|
||||
const normalizedDocs = useMemo(
|
||||
() => normalizeDocCoordinates(documents),
|
||||
[documents],
|
||||
)
|
||||
|
||||
const nodes = useMemo(() => {
|
||||
if (!normalizedDocs || normalizedDocs.length === 0) return []
|
||||
if (!documents || documents.length === 0) return []
|
||||
|
||||
const result: GraphNode[] = []
|
||||
// Place nodes in the canvas space; force simulation will refine positions
|
||||
const spreadW = Math.max(canvasWidth * 0.8, 400)
|
||||
const spreadH = Math.max(canvasHeight * 0.8, 400)
|
||||
const padX = (canvasWidth - spreadW) / 2
|
||||
const padY = (canvasHeight - spreadH) / 2
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
const initialX = doc.x * scale + offsetX
|
||||
const initialY = doc.y * scale + offsetY
|
||||
for (const doc of documents) {
|
||||
// Deterministic initial position based on doc id
|
||||
const initialX = padX + hashToUnit(doc.id) * spreadW
|
||||
const initialY = padY + hashToUnit(`${doc.id}-y`) * spreadH
|
||||
|
||||
let docNode = nodeCache.current.get(doc.id)
|
||||
const docData: DocumentNodeData = {
|
||||
|
|
@ -177,122 +156,81 @@ export function useGraphData(
|
|||
}
|
||||
|
||||
return result
|
||||
}, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId, colors])
|
||||
}, [documents, canvasWidth, canvasHeight, draggingNodeId, colors])
|
||||
|
||||
const edges = useMemo(() => {
|
||||
if (!normalizedDocs || normalizedDocs.length === 0) return []
|
||||
if (!documents || documents.length === 0) return []
|
||||
|
||||
const result: GraphEdge[] = []
|
||||
// Build allNodeIds from normalizedDocs directly to avoid depending on `nodes`
|
||||
// (which changes identity on every render due to draggingNodeId/colors deps)
|
||||
const allNodeIds = new Set<string>()
|
||||
for (const doc of normalizedDocs) {
|
||||
for (const doc of documents) {
|
||||
allNodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) allNodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
// Doc-memory edges
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
result.push({
|
||||
id: `dm-${doc.id}-${mem.id}`,
|
||||
source: doc.id,
|
||||
target: mem.id,
|
||||
similarity: 1,
|
||||
visualProps: { opacity: 0.3, thickness: 1.5 },
|
||||
visualProps: getEdgeVisualProps("doc-memory"),
|
||||
edgeType: "doc-memory",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
// Version chain edges
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
if (mem.parentMemoryId && allNodeIds.has(mem.parentMemoryId)) {
|
||||
result.push({
|
||||
id: `ver-${mem.parentMemoryId}-${mem.id}`,
|
||||
source: mem.parentMemoryId,
|
||||
target: mem.id,
|
||||
similarity: 1,
|
||||
visualProps: { opacity: 0.6, thickness: 2 },
|
||||
visualProps: getEdgeVisualProps("version"),
|
||||
edgeType: "version",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const apiEdge of apiEdges) {
|
||||
if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) {
|
||||
continue
|
||||
// Same-space edges: connect documents that share a spaceId
|
||||
const spaceGroups = new Map<string, string[]>()
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
const group = spaceGroups.get(mem.spaceId)
|
||||
if (group) {
|
||||
if (!group.includes(doc.id)) group.push(doc.id)
|
||||
} else {
|
||||
spaceGroups.set(mem.spaceId, [doc.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
const addedPairs = new Set<string>()
|
||||
for (const docIds of spaceGroups.values()) {
|
||||
for (let i = 0; i < docIds.length; i++) {
|
||||
for (let j = i + 1; j < docIds.length; j++) {
|
||||
const a = docIds[i]!
|
||||
const b = docIds[j]!
|
||||
const key = a < b ? `${a}:${b}` : `${b}:${a}`
|
||||
if (!addedPairs.has(key)) {
|
||||
addedPairs.add(key)
|
||||
result.push({
|
||||
id: `ss-${key}`,
|
||||
source: a,
|
||||
target: b,
|
||||
visualProps: getEdgeVisualProps("same-space"),
|
||||
edgeType: "same-space",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: `sim-${apiEdge.source}-${apiEdge.target}`,
|
||||
source: apiEdge.source,
|
||||
target: apiEdge.target,
|
||||
similarity: apiEdge.similarity,
|
||||
visualProps: getEdgeVisualProps(apiEdge.similarity),
|
||||
edgeType: "similarity",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [normalizedDocs, apiEdges])
|
||||
}, [documents])
|
||||
|
||||
return { nodes, edges, scale, offsetX, offsetY }
|
||||
}
|
||||
|
||||
export function screenToBackendCoords(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
panX: number,
|
||||
panY: number,
|
||||
zoom: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): { x: number; y: number } {
|
||||
const canvasX = (screenX - panX) / zoom
|
||||
const canvasY = (screenY - panY) / zoom
|
||||
|
||||
const paddingFactor = 0.8
|
||||
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
|
||||
const ox = (canvasWidth - 1000 * s) / 2
|
||||
const oy = (canvasHeight - 1000 * s) / 2
|
||||
|
||||
return {
|
||||
x: (canvasX - ox) / s,
|
||||
y: (canvasY - oy) / s,
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateBackendViewport(
|
||||
panX: number,
|
||||
panY: number,
|
||||
zoom: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): { minX: number; maxX: number; minY: number; maxY: number } {
|
||||
const topLeft = screenToBackendCoords(
|
||||
0,
|
||||
0,
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
)
|
||||
const bottomRight = screenToBackendCoords(
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
)
|
||||
|
||||
return {
|
||||
minX: Math.max(0, Math.min(topLeft.x, bottomRight.x)),
|
||||
maxX: Math.max(topLeft.x, bottomRight.x),
|
||||
minY: Math.max(0, Math.min(topLeft.y, bottomRight.y)),
|
||||
maxY: Math.max(topLeft.y, bottomRight.y),
|
||||
}
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,19 +37,10 @@ function resolveColors(): GraphThemeColors {
|
|||
DEFAULT_COLORS.edgeDocMemory,
|
||||
),
|
||||
edgeVersion: readCssVar("--graph-edge-version", DEFAULT_COLORS.edgeVersion),
|
||||
edgeSimStrong: readCssVar(
|
||||
"--graph-edge-sim-strong",
|
||||
DEFAULT_COLORS.edgeSimStrong,
|
||||
edgeSameSpace: readCssVar(
|
||||
"--graph-edge-same-space",
|
||||
DEFAULT_COLORS.edgeSameSpace,
|
||||
),
|
||||
edgeSimMedium: readCssVar(
|
||||
"--graph-edge-sim-medium",
|
||||
DEFAULT_COLORS.edgeSimMedium,
|
||||
),
|
||||
edgeSimWeak: readCssVar(
|
||||
"--graph-edge-sim-weak",
|
||||
DEFAULT_COLORS.edgeSimWeak,
|
||||
),
|
||||
edgeDocDoc: readCssVar("--graph-edge-doc-doc", DEFAULT_COLORS.edgeDocDoc),
|
||||
memBorderForgotten: readCssVar(
|
||||
"--graph-mem-border-forgotten",
|
||||
DEFAULT_COLORS.memBorderForgotten,
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ export type {
|
|||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
GraphApiEdge,
|
||||
GraphViewportResponse,
|
||||
GraphBoundsResponse,
|
||||
GraphStatsResponse,
|
||||
DocumentNodeData,
|
||||
MemoryNodeData,
|
||||
ChainEntry,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import type { GraphApiDocument, GraphApiEdge, GraphApiMemory } from "./types"
|
||||
import type { GraphApiDocument, GraphApiMemory } from "./types"
|
||||
|
||||
export interface MockGraphOptions {
|
||||
documentCount?: number
|
||||
memoriesPerDoc?: number | [number, number]
|
||||
similarityEdgeRatio?: number
|
||||
seed?: number
|
||||
}
|
||||
|
||||
|
|
@ -246,12 +245,10 @@ function generateISODate(
|
|||
|
||||
export function generateMockGraphData(options: MockGraphOptions = {}): {
|
||||
documents: GraphApiDocument[]
|
||||
edges: GraphApiEdge[]
|
||||
} {
|
||||
const {
|
||||
documentCount = 100,
|
||||
memoriesPerDoc = [2, 6] as [number, number],
|
||||
similarityEdgeRatio = 0.1,
|
||||
seed = 42,
|
||||
} = options
|
||||
|
||||
|
|
@ -371,10 +368,6 @@ export function generateMockGraphData(options: MockGraphOptions = {}): {
|
|||
})
|
||||
}
|
||||
|
||||
// Position documents spread across a 1000x1000 space
|
||||
const x = random() * 1000
|
||||
const y = random() * 1000
|
||||
|
||||
documents.push({
|
||||
id: docId,
|
||||
title: generateTitle(random),
|
||||
|
|
@ -383,69 +376,9 @@ export function generateMockGraphData(options: MockGraphOptions = {}): {
|
|||
DOCUMENT_TYPES[Math.floor(random() * DOCUMENT_TYPES.length)],
|
||||
createdAt: docCreatedAt,
|
||||
updatedAt: docUpdatedAt,
|
||||
x,
|
||||
y,
|
||||
memories,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate similarity edges between random document pairs
|
||||
const edges: GraphApiEdge[] = []
|
||||
const totalPossiblePairs = (documentCount * (documentCount - 1)) / 2
|
||||
const targetEdgeCount = Math.max(
|
||||
0,
|
||||
Math.floor(totalPossiblePairs * similarityEdgeRatio),
|
||||
)
|
||||
|
||||
// Use a set to avoid duplicate pairs
|
||||
const edgeSet = new Set<string>()
|
||||
|
||||
// For small document counts, iterate all pairs; for large, sample randomly
|
||||
if (documentCount <= 50 || targetEdgeCount > totalPossiblePairs * 0.5) {
|
||||
// Iterate all pairs and include based on probability
|
||||
for (let i = 0; i < documentCount; i++) {
|
||||
for (let j = i + 1; j < documentCount; j++) {
|
||||
if (random() < similarityEdgeRatio) {
|
||||
const sourceId = documents[i].id
|
||||
const targetId = documents[j].id
|
||||
const key = `${sourceId}:${targetId}`
|
||||
if (!edgeSet.has(key)) {
|
||||
edgeSet.add(key)
|
||||
// Similarity weighted towards medium-high values
|
||||
const similarity = 0.3 + random() * 0.7
|
||||
edges.push({
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
similarity: Math.round(similarity * 1000) / 1000,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Random sampling for large document counts
|
||||
let attempts = 0
|
||||
const maxAttempts = targetEdgeCount * 5
|
||||
while (edges.length < targetEdgeCount && attempts < maxAttempts) {
|
||||
attempts++
|
||||
const i = Math.floor(random() * documentCount)
|
||||
const j = Math.floor(random() * documentCount)
|
||||
if (i === j) continue
|
||||
const sourceIdx = Math.min(i, j)
|
||||
const targetIdx = Math.max(i, j)
|
||||
const sourceId = documents[sourceIdx].id
|
||||
const targetId = documents[targetIdx].id
|
||||
const key = `${sourceId}:${targetId}`
|
||||
if (edgeSet.has(key)) continue
|
||||
edgeSet.add(key)
|
||||
const similarity = 0.3 + random() * 0.7
|
||||
edges.push({
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
similarity: Math.round(similarity * 1000) / 1000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { documents, edges }
|
||||
return { documents }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,42 +23,13 @@ export interface GraphApiDocument {
|
|||
documentType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
x: number
|
||||
y: number
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
export interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
export interface GraphViewportResponse {
|
||||
documents: GraphApiDocument[]
|
||||
edges: GraphApiEdge[]
|
||||
viewport: {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export interface GraphBoundsResponse {
|
||||
bounds: {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface GraphStatsResponse {
|
||||
totalDocuments: number
|
||||
documentsWithSpatial: number
|
||||
totalDocumentEdges: number
|
||||
edgeType: "doc-memory" | "version" | "same-space"
|
||||
}
|
||||
|
||||
// Typed node data
|
||||
|
|
@ -111,12 +82,11 @@ export interface GraphEdge {
|
|||
id: string
|
||||
source: string | GraphNode
|
||||
target: string | GraphNode
|
||||
similarity: number
|
||||
visualProps: {
|
||||
opacity: number
|
||||
thickness: number
|
||||
}
|
||||
edgeType: "doc-memory" | "similarity" | "version"
|
||||
edgeType: "doc-memory" | "version" | "same-space"
|
||||
}
|
||||
|
||||
export interface GraphThemeColors {
|
||||
|
|
@ -133,10 +103,7 @@ export interface GraphThemeColors {
|
|||
textMuted: string
|
||||
edgeDocMemory: string
|
||||
edgeVersion: string
|
||||
edgeSimStrong: string
|
||||
edgeSimMedium: string
|
||||
edgeSimWeak: string
|
||||
edgeDocDoc: string
|
||||
edgeSameSpace: string
|
||||
memBorderForgotten: string
|
||||
memBorderExpiring: string
|
||||
memBorderRecent: string
|
||||
|
|
@ -174,10 +141,14 @@ export interface GraphCanvasProps {
|
|||
export interface MemoryGraphProps {
|
||||
/** Documents to display - pass this for direct data mode */
|
||||
documents?: GraphApiDocument[]
|
||||
/** API edges between documents */
|
||||
apiEdges?: GraphApiEdge[]
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean
|
||||
/** Whether more data is being loaded */
|
||||
isLoadingMore?: boolean
|
||||
/** Callback to load more documents */
|
||||
onLoadMore?: () => void
|
||||
/** Whether there are more documents to load */
|
||||
hasMore?: boolean
|
||||
/** Error from data fetching */
|
||||
error?: Error | null
|
||||
/** Children to render when no documents */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue