supermemory/apps/memory-graph-playground/src/app/page.tsx
Dhravya Shah 053baa5029
Improve memory graph (#973)
Co-authored-by: Ishaan Gupta <ishaankone@gmail.com>
2026-06-05 15:48:41 -07:00

626 lines
19 KiB
TypeScript

"use client"
import { useState, useCallback, useEffect, useMemo } from "react"
import {
MemoryGraph,
type GraphApiDocument,
type GraphApiMemory,
type GraphThemeColors,
type MemoryRelation,
} from "@supermemory/memory-graph"
import { generateMockGraphData } from "@supermemory/memory-graph/mock-data"
interface PlaygroundApiMemory {
id: string
memory?: string | null
content?: string | null
isStatic?: boolean
spaceId?: string | null
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 PlaygroundApiDocument {
id: string
title: string | null
summary?: string | null
documentType?: string
type?: string
containerTags?: string[]
createdAt: string
updatedAt: string
memories?: PlaygroundApiMemory[]
memoryEntries?: PlaygroundApiMemory[]
}
interface DocumentsResponse {
documents: PlaygroundApiDocument[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
interface ContainerTagOption {
id: string
name?: string | null
containerTag: string
documentCount?: number
memoryCount?: number
lastActivityAt?: string | null
}
type GraphVariant = "consumer" | "console"
type LoadBehavior = "zoom" | "manual" | "background"
const PAGE_SIZE = 100
const BACKGROUND_LOAD_DELAY_MS = 900
const CONSUMER_GRAPH_COLORS = {
bg: "transparent",
edgeDerives: "#9ca3af",
} satisfies Partial<GraphThemeColors>
/** Convert the external API format to the internal graph format */
function toGraphDocuments(docs: PlaygroundApiDocument[]): GraphApiDocument[] {
return docs.map((doc) => {
const memories = doc.memories ?? doc.memoryEntries ?? []
return {
id: doc.id,
title: doc.title,
summary: doc.summary ?? null,
documentType: doc.documentType ?? doc.type ?? "unknown",
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: memories.map(
(mem): GraphApiMemory => ({
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,
}),
),
}
})
}
export default function Home() {
const [apiKey, setApiKey] = useState("")
const [containerTag, setContainerTag] = useState("")
const [containerTags, setContainerTags] = useState<ContainerTagOption[]>([])
const [isLoadingContainerTags, setIsLoadingContainerTags] = useState(false)
const [containerTagsError, setContainerTagsError] = useState<Error | null>(
null,
)
const [documents, setDocuments] = useState<PlaygroundApiDocument[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [showGraph, setShowGraph] = useState(false)
const [stressTestCount, setStressTestCount] = useState(0)
const [graphVariant, setGraphVariant] = useState<GraphVariant>("consumer")
const [loadBehavior, setLoadBehavior] = useState<LoadBehavior>("zoom")
const [pagination, setPagination] = useState<
DocumentsResponse["pagination"] | null
>(null)
// State for slideshow
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
// Mock data for stress testing
const [mockData, setMockData] = useState<{
documents: GraphApiDocument[]
} | null>(null)
const selectedContainerTags = useMemo(() => {
const trimmed = containerTag.trim()
return trimmed ? [trimmed] : undefined
}, [containerTag])
const fetchContainerTags = useCallback(async () => {
if (!apiKey || isLoadingContainerTags) return
setIsLoadingContainerTags(true)
setContainerTagsError(null)
try {
const response = await fetch("/api/container-tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to fetch container tags")
}
const data = (await response.json()) as ContainerTagOption[]
setContainerTags(data)
} catch (err) {
setContainerTagsError(
err instanceof Error ? err : new Error("Unknown error"),
)
} finally {
setIsLoadingContainerTags(false)
}
}, [apiKey, isLoadingContainerTags])
const fetchDocuments = useCallback(
async (page: number, append = false) => {
if (!apiKey) return
if (append) {
setIsLoadingMore(true)
} else {
setIsLoading(true)
}
setError(null)
try {
const response = await fetch("/api/graph", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
apiKey,
page,
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags: selectedContainerTags,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to fetch documents")
}
const data: DocumentsResponse = await response.json()
if (append) {
setDocuments((prev) => [...prev, ...data.documents])
} else {
setDocuments(data.documents)
}
setPagination(data.pagination)
setShowGraph(true)
setMockData(null)
setStressTestCount(0)
} catch (err) {
setError(err instanceof Error ? err : new Error("Unknown error"))
} finally {
setIsLoading(false)
setIsLoadingMore(false)
}
},
[apiKey, selectedContainerTags],
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey) {
setDocuments([])
setPagination(null)
void fetchContainerTags()
fetchDocuments(1)
}
}
const handleLoadMoreDocuments = useCallback(() => {
if (!pagination || pagination.currentPage >= pagination.totalPages) return
fetchDocuments(pagination.currentPage + 1, true)
}, [fetchDocuments, pagination])
const handleStressTest = (count: number) => {
const data = generateMockGraphData({
documentCount: count,
memoriesPerDoc: [2, 5],
seed: 12345,
})
setMockData({ documents: data.documents })
setDocuments([])
setPagination(null)
setStressTestCount(count)
setShowGraph(true)
setError(null)
}
// Toggle slideshow
const handleToggleSlideshow = () => {
setIsSlideshowActive((prev) => !prev)
}
// Handle slideshow node change
const handleSlideshowNodeChange = useCallback((nodeId: string | null) => {
console.log("Slideshow showing node:", nodeId)
}, [])
// Handle slideshow stop
const handleSlideshowStop = useCallback(() => {
setIsSlideshowActive(false)
}, [])
// Convert real documents to graph format
const graphDocuments = useMemo(() => {
if (mockData) return mockData.documents
return toGraphDocuments(documents)
}, [documents, mockData])
const availableContainerTags = useMemo(() => {
const options = new Map<string, ContainerTagOption>()
for (const tag of containerTags) {
if (tag.containerTag) options.set(tag.containerTag, tag)
}
for (const doc of documents) {
for (const tag of doc.containerTags ?? []) {
if (tag && !options.has(tag)) {
options.set(tag, { id: tag, containerTag: tag, name: tag })
}
}
const memories = doc.memories ?? doc.memoryEntries ?? []
for (const mem of memories) {
const tag = mem.spaceContainerTag
if (tag && !options.has(tag)) {
options.set(tag, { id: tag, containerTag: tag, name: tag })
}
}
}
return [...options.values()]
}, [containerTags, documents])
const displayCount = mockData ? stressTestCount : documents.length
const hasMore =
!mockData &&
pagination != null &&
pagination.currentPage < pagination.totalPages
const totalCount = mockData
? stressTestCount
: (pagination?.totalItems ?? documents.length)
const maxNodes = mockData ? 1000 : undefined
const graphHandlesLoadMore = loadBehavior === "zoom"
useEffect(() => {
if (
loadBehavior !== "background" ||
!showGraph ||
mockData ||
!hasMore ||
isLoading ||
isLoadingMore ||
error
) {
return
}
const timer = window.setTimeout(
handleLoadMoreDocuments,
BACKGROUND_LOAD_DELAY_MS,
)
return () => window.clearTimeout(timer)
}, [
error,
handleLoadMoreDocuments,
hasMore,
isLoading,
isLoadingMore,
loadBehavior,
mockData,
showGraph,
])
return (
<div className="flex flex-col h-screen bg-zinc-950">
{/* Header */}
<header className="shrink-0 border-b border-zinc-800 bg-zinc-900 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">
Memory Graph Playground
</h1>
<p className="text-sm text-zinc-400">
Test the @supermemory/memory-graph package
</p>
</div>
<form onSubmit={handleSubmit} className="flex items-center gap-3">
<input
type="password"
placeholder="Enter your Supermemory API key"
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value)
setContainerTags([])
setContainerTagsError(null)
}}
className="w-80 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-white placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex items-center gap-2">
<input
list="container-tag-options"
value={containerTag}
onChange={(e) => setContainerTag(e.target.value)}
onFocus={() => {
if (availableContainerTags.length === 0) {
void fetchContainerTags()
}
}}
disabled={!apiKey}
placeholder={
isLoadingContainerTags
? "Loading container tags..."
: "All container tags"
}
className="w-64 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<datalist id="container-tag-options">
{availableContainerTags.map((tag) => (
<option key={tag.containerTag} value={tag.containerTag}>
{tag.name && tag.name !== tag.containerTag
? `${tag.name} (${tag.containerTag})`
: tag.containerTag}
</option>
))}
</datalist>
<button
type="button"
onClick={() => void fetchContainerTags()}
disabled={!apiKey || isLoadingContainerTags}
className="rounded-lg border border-zinc-700 px-3 py-2 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoadingContainerTags ? "Loading..." : "Tags"}
</button>
</div>
<button
type="submit"
disabled={!apiKey || isLoading}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? "Loading..." : "Load Graph"}
</button>
</form>
</div>
</header>
{/* Controls Panel */}
<div className="shrink-0 border-b border-zinc-800 bg-zinc-900/50 px-6 py-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-zinc-400">Documents:</span>
<span className="font-mono text-emerald-400">{displayCount}</span>
</div>
{stressTestCount > 0 && (
<span className="rounded bg-amber-900/50 px-2 py-0.5 text-xs text-amber-400">
Stress Test Mode
</span>
)}
{pagination && !mockData && (
<span className="font-mono text-xs text-zinc-500">
Page {pagination.currentPage}/{pagination.totalPages}
</span>
)}
{selectedContainerTags && (
<span className="rounded bg-sky-950/70 px-2 py-0.5 font-mono text-xs text-sky-300">
{selectedContainerTags[0]}
</span>
)}
{containerTagsError && (
<span className="text-xs text-amber-300">
{containerTagsError.message}
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-zinc-500 text-xs">Mode:</span>
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
{(["consumer", "console"] as const).map((variant) => (
<button
key={variant}
type="button"
onClick={() => setGraphVariant(variant)}
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
graphVariant === variant
? "bg-blue-600 text-white"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
}`}
aria-pressed={graphVariant === variant}
>
{variant}
</button>
))}
</div>
<div className="h-6 w-px bg-zinc-700" />
<span className="text-zinc-500 text-xs">Load:</span>
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
{(["zoom", "manual", "background"] as const).map((behavior) => (
<button
key={behavior}
type="button"
onClick={() => setLoadBehavior(behavior)}
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
loadBehavior === behavior
? "bg-emerald-600 text-white"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
}`}
aria-pressed={loadBehavior === behavior}
>
{behavior}
</button>
))}
</div>
{loadBehavior === "manual" && (
<button
type="button"
onClick={handleLoadMoreDocuments}
disabled={!hasMore || isLoadingMore}
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoadingMore
? "Loading..."
: hasMore
? "Load next"
: "All loaded"}
</button>
)}
{loadBehavior === "background" && !mockData && hasMore && (
<span className="rounded bg-emerald-950/70 px-2 py-0.5 text-xs text-emerald-300">
Auto paging
</span>
)}
<div className="h-6 w-px bg-zinc-700" />
{/* Stress test buttons */}
<span className="text-zinc-500 text-xs">Stress Test:</span>
{[50, 100, 200, 500].map((count) => (
<button
key={count}
type="button"
onClick={() => handleStressTest(count)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
stressTestCount === count
? "bg-amber-600 text-white"
: "border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
}`}
>
{count} docs
</button>
))}
<div className="h-6 w-px bg-zinc-700" />
<button
type="button"
onClick={handleToggleSlideshow}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5 ${
isSlideshowActive
? "bg-blue-600 text-white hover:bg-blue-700"
: "border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
}`}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
{isSlideshowActive ? (
<rect x="6" y="6" width="12" height="12" />
) : (
<path d="M8 5v14l11-7z" />
)}
</svg>
Slideshow
</button>
</div>
</div>
</div>
{/* Main content */}
<main className="flex-1 overflow-hidden">
{!showGraph ? (
<div className="flex h-full items-center justify-center">
<div className="max-w-md text-center">
<div className="mb-6 text-6xl">
<svg
className="mx-auto h-16 w-16 text-zinc-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</div>
<h2 className="mb-2 text-xl font-semibold text-white">
Get Started
</h2>
<p className="mb-6 text-zinc-400">
Enter your API key above, or click a stress test button to
generate mock data.
</p>
<div className="text-left text-sm text-zinc-500">
<p className="mb-2 font-medium text-zinc-400">
Features to test:
</p>
<ul className="list-inside list-disc space-y-1">
<li>Pan and zoom the graph</li>
<li>Click on nodes to see details</li>
<li>Drag nodes around</li>
<li>Arrow key navigation</li>
<li>Stress test with 50-500 documents</li>
<li>FPS counter (shown during stress tests)</li>
</ul>
</div>
</div>
</div>
) : (
<div className="h-full w-full">
<MemoryGraph
documents={graphDocuments}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
hasMore={graphHandlesLoadMore && hasMore}
onLoadMore={
graphHandlesLoadMore && hasMore
? handleLoadMoreDocuments
: undefined
}
error={error}
variant={graphVariant}
maxNodes={maxNodes}
showFps={stressTestCount > 0}
isSlideshowActive={isSlideshowActive}
onSlideshowNodeChange={handleSlideshowNodeChange}
onSlideshowStop={handleSlideshowStop}
totalCount={totalCount}
colors={
graphVariant === "consumer" ? CONSUMER_GRAPH_COLORS : undefined
}
>
<div className="flex h-full items-center justify-center">
<p className="text-zinc-400">
No memories found. Add some content to see your graph.
</p>
</div>
</MemoryGraph>
</div>
)}
</main>
</div>
)
}