mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-22 11:08:50 +00:00
graph frontend (#713)
This commit is contained in:
parent
7b6793ecf8
commit
5dd358a939
40 changed files with 6100 additions and 396 deletions
2
apps/memory-graph-playground/next-env.d.ts
vendored
2
apps/memory-graph-playground/next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from "react"
|
|||
import { Header } from "@/components/new/header"
|
||||
import { ChatSidebar } from "@/components/new/chat"
|
||||
import { MemoriesGrid } from "@/components/new/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/new/graph-layout-view"
|
||||
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
|
||||
import { AddDocumentModal } from "@/components/new/add-document"
|
||||
import { MCPModal } from "@/components/new/mcp-modal"
|
||||
|
|
@ -25,6 +26,8 @@ import { useDocumentMutations } from "@/hooks/use-document-mutations"
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -32,6 +35,7 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
|
|||
export default function NewPage() {
|
||||
const isMobile = useIsMobile()
|
||||
const { selectedProject } = useProject()
|
||||
const { viewMode } = useViewMode()
|
||||
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
|
||||
const [isMCPModalOpen, setIsMCPModalOpen] = useState(false)
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
|
|
@ -54,16 +58,33 @@ export default function NewPage() {
|
|||
},
|
||||
})
|
||||
|
||||
// Fetch space highlights (highlights + suggested questions)
|
||||
type SpaceHighlightsResponse = {
|
||||
highlights: HighlightItem[]
|
||||
questions: string[]
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1"
|
||||
const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours
|
||||
|
||||
const { data: highlightsData, isLoading: isLoadingHighlights } =
|
||||
useQuery<SpaceHighlightsResponse>({
|
||||
queryKey: ["space-highlights", selectedProject],
|
||||
queryFn: async (): Promise<SpaceHighlightsResponse> => {
|
||||
const spaceId = selectedProject || "sm_project_default"
|
||||
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
|
||||
|
||||
// Check Cache API for a fresh response
|
||||
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cached = await cache.match(cacheKey)
|
||||
if (cached) {
|
||||
const age =
|
||||
Date.now() - Number(cached.headers.get("x-cached-at") || 0)
|
||||
if (age < HIGHLIGHTS_MAX_AGE) {
|
||||
return cached.json()
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`,
|
||||
{
|
||||
|
|
@ -71,7 +92,7 @@ export default function NewPage() {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
spaceId: selectedProject || "sm_project_default",
|
||||
spaceId,
|
||||
highlightsCount: 3,
|
||||
questionsCount: 4,
|
||||
includeHighlights: true,
|
||||
|
|
@ -84,9 +105,20 @@ export default function NewPage() {
|
|||
throw new Error("Failed to fetch space highlights")
|
||||
}
|
||||
|
||||
return response.json()
|
||||
const data = await response.json()
|
||||
|
||||
// Store in Cache API with timestamp
|
||||
const cacheResponse = new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-cached-at": String(Date.now()),
|
||||
},
|
||||
})
|
||||
await cache.put(cacheKey, cacheResponse)
|
||||
|
||||
return data
|
||||
},
|
||||
staleTime: 4 * 60 * 60 * 1000, // 4 hours (matches backend cache)
|
||||
staleTime: HIGHLIGHTS_MAX_AGE,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
|
|
@ -172,13 +204,26 @@ export default function NewPage() {
|
|||
setIsSearchOpen(true)
|
||||
}, [])
|
||||
|
||||
const isGraphMode = viewMode === "graph" && !isMobile
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
<div className="bg-black min-h-screen">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-black min-h-screen",
|
||||
isGraphMode && "h-screen overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<AnimatedGradientBackground
|
||||
topPosition="15%"
|
||||
animateFromBottom={false}
|
||||
/>
|
||||
{isGraphMode && (
|
||||
<div
|
||||
id="graph-dotted-grid"
|
||||
className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_center,#69A7F0_0.1px,transparent_1px)] bg-size-[24px_24px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
|
||||
/>
|
||||
)}
|
||||
<Header
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
|
|
@ -195,36 +240,47 @@ export default function NewPage() {
|
|||
}}
|
||||
/>
|
||||
<main
|
||||
key={`main-container-${isChatOpen}`}
|
||||
className="z-10 flex flex-col md:flex-row relative"
|
||||
key={`main-container-${isChatOpen}-${viewMode}`}
|
||||
className={cn(
|
||||
"z-10 relative",
|
||||
isGraphMode && "h-[calc(100vh-86px)] overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
|
||||
<MemoriesGrid
|
||||
isChatOpen={isChatOpen}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
quickNoteProps={{
|
||||
onSave: handleQuickNoteSave,
|
||||
onMaximize: handleMaximize,
|
||||
isSaving: noteMutation.isPending,
|
||||
}}
|
||||
highlightsProps={{
|
||||
items: highlightsData?.highlights || [],
|
||||
onChat: handleHighlightsChat,
|
||||
onShowRelated: handleHighlightsShowRelated,
|
||||
isLoading: isLoadingHighlights,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar
|
||||
isChatOpen={isChatOpen}
|
||||
setIsChatOpen={setIsChatOpen}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
<div className={cn("relative z-10 flex flex-col md:flex-row h-full")}>
|
||||
{viewMode === "graph" && !isMobile ? (
|
||||
<div className="flex-1">
|
||||
<GraphLayoutView isChatOpen={isChatOpen} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
|
||||
<MemoriesGrid
|
||||
isChatOpen={isChatOpen}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
quickNoteProps={{
|
||||
onSave: handleQuickNoteSave,
|
||||
onMaximize: handleMaximize,
|
||||
isSaving: noteMutation.isPending,
|
||||
}}
|
||||
highlightsProps={{
|
||||
items: highlightsData?.highlights || [],
|
||||
onChat: handleHighlightsChat,
|
||||
onShowRelated: handleHighlightsShowRelated,
|
||||
isLoading: isLoadingHighlights,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar
|
||||
isChatOpen={isChatOpen}
|
||||
setIsChatOpen={setIsChatOpen}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { z } from "zod"
|
||||
import { MemoryGraph } from "@repo/ui/memory-graph"
|
||||
import { useState } from "react"
|
||||
import { MemoryGraph } from "@/components/new/memory-graph/memory-graph"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { ConnectAIModal } from "@/components/connect-ai-modal"
|
||||
import { AddMemoryView } from "@/components/views/add-memory"
|
||||
|
|
@ -14,179 +10,39 @@ import { useChatOpen, useProject, useGraphModal } from "@/stores"
|
|||
import { useGraphHighlights } from "@/stores/highlights"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
/**
|
||||
* Graph Dialog component
|
||||
*/
|
||||
export function GraphDialog() {
|
||||
const { user } = useAuth()
|
||||
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
|
||||
const { selectedProject } = useProject()
|
||||
const { isOpen } = useChatOpen()
|
||||
const { isOpen: isChatOpen } = useChatOpen()
|
||||
const { isOpen: showGraphModal, setIsOpen: setShowGraphModal } =
|
||||
useGraphModal()
|
||||
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
|
||||
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
|
||||
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === "development"
|
||||
const PAGE_SIZE = IS_DEV ? 100 : 100
|
||||
const MAX_TOTAL = 1000
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery<DocumentsResponse, Error>({
|
||||
queryKey: ["documents-with-memories", selectedProject],
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
page: pageParam as number,
|
||||
limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: selectedProject ? [selectedProject] : undefined,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to fetch documents")
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (!lastPage || !lastPage.pagination) return undefined
|
||||
if (!Array.isArray(allPages)) return undefined
|
||||
|
||||
const loaded = allPages.reduce(
|
||||
(acc, p) => acc + (p.documents?.length ?? 0),
|
||||
0,
|
||||
)
|
||||
if (loaded >= MAX_TOTAL) return undefined
|
||||
|
||||
const { currentPage, totalPages } = lastPage.pagination
|
||||
if (currentPage < totalPages) {
|
||||
return currentPage + 1
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!user, // Only run query if user is authenticated
|
||||
})
|
||||
|
||||
const baseDocuments = useMemo(() => {
|
||||
return (
|
||||
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
|
||||
)
|
||||
}, [data])
|
||||
|
||||
const allDocuments = useMemo(() => {
|
||||
if (injectedDocs.length === 0) return baseDocuments
|
||||
const byId = new Map<string, DocumentWithMemories>()
|
||||
for (const d of injectedDocs) byId.set(d.id, d)
|
||||
for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d)
|
||||
return Array.from(byId.values())
|
||||
}, [baseDocuments, injectedDocs])
|
||||
|
||||
const totalLoaded = allDocuments.length
|
||||
const hasMore = hasNextPage
|
||||
const isLoadingMore = isFetchingNextPage
|
||||
|
||||
const loadMoreDocuments = useCallback(async (): Promise<void> => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
await fetchNextPage()
|
||||
return
|
||||
}
|
||||
return
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||
|
||||
// Handle highlighted documents injection for chat
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0) return
|
||||
const present = new Set<string>()
|
||||
for (const d of [...baseDocuments, ...injectedDocs]) {
|
||||
if (d.id) present.add(d.id)
|
||||
if (d.customId) present.add(d.customId as string)
|
||||
}
|
||||
const missing = allHighlightDocumentIds.filter(
|
||||
(id: string) => !present.has(id),
|
||||
)
|
||||
if (missing.length === 0) return
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
const resp = await $fetch("@post/documents/documents/by-ids", {
|
||||
body: {
|
||||
ids: missing,
|
||||
by: "customId",
|
||||
containerTags: selectedProject ? [selectedProject] : undefined,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
if (cancelled || resp?.error) return
|
||||
const extraDocs = resp?.data?.documents as
|
||||
| DocumentWithMemories[]
|
||||
| undefined
|
||||
if (!extraDocs || extraDocs.length === 0) return
|
||||
setInjectedDocs((prev) => {
|
||||
const seen = new Set<string>([
|
||||
...prev.map((d) => d.id),
|
||||
...baseDocuments.map((d) => d.id),
|
||||
])
|
||||
const merged = [...prev]
|
||||
for (const doc of extraDocs) {
|
||||
if (!seen.has(doc.id)) {
|
||||
merged.push(doc)
|
||||
seen.add(doc.id)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
allHighlightDocumentIds,
|
||||
baseDocuments,
|
||||
injectedDocs,
|
||||
selectedProject,
|
||||
])
|
||||
|
||||
if (!user) return null
|
||||
|
||||
// Convert selectedProject to containerTags array
|
||||
const containerTags = selectedProject ? [selectedProject] : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={showGraphModal} onOpenChange={setShowGraphModal}>
|
||||
<DialogContent
|
||||
className="w-[95vw] h-[95vh] p-0 max-w-6xl sm:max-w-6xl"
|
||||
className="w-[95vw] h-[95vh] p-0 max-w-6xl sm:max-w-6xl"
|
||||
showCloseButton={true}
|
||||
>
|
||||
<DialogTitle className="sr-only">Memory Graph</DialogTitle>
|
||||
<div className="w-full h-full">
|
||||
<MemoryGraph
|
||||
documents={allDocuments}
|
||||
error={error}
|
||||
hasMore={hasMore}
|
||||
isLoading={isPending}
|
||||
isLoadingMore={isLoadingMore}
|
||||
loadMoreDocuments={loadMoreDocuments}
|
||||
totalLoaded={totalLoaded}
|
||||
containerTags={containerTags}
|
||||
variant="console"
|
||||
showSpacesSelector={true}
|
||||
highlightDocumentIds={allHighlightDocumentIds}
|
||||
highlightsVisible={isOpen}
|
||||
highlightsVisible={isChatOpen}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{!isMobile ? (
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { ChainOfThought } from "./input/chain-of-thought"
|
|||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
|
||||
const DEFAULT_SUGGESTIONS = [
|
||||
"Show me all content related to Supermemory.",
|
||||
|
|
@ -135,15 +136,17 @@ export function ChatSidebar({
|
|||
const pendingFollowUpGenerations = useRef<Set<string>>(new Set())
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { selectedProject } = useProject()
|
||||
const { viewMode } = useViewMode()
|
||||
const [currentChatId, setCurrentChatId] = useState<string>(() => generateId())
|
||||
const [pendingThreadLoad, setPendingThreadLoad] = useState<{
|
||||
id: string
|
||||
messages: UIMessage[]
|
||||
} | null>(null)
|
||||
|
||||
// Adjust chat height based on scroll position (desktop only)
|
||||
// Adjust chat height based on scroll position (desktop only, grid mode only)
|
||||
useEffect(() => {
|
||||
if (isMobile) return
|
||||
if (viewMode === "graph") return
|
||||
|
||||
const handleWindowScroll = () => {
|
||||
const scrollThreshold = 80
|
||||
|
|
@ -157,7 +160,7 @@ export function ChatSidebar({
|
|||
handleWindowScroll()
|
||||
|
||||
return () => window.removeEventListener("scroll", handleWindowScroll)
|
||||
}, [isMobile])
|
||||
}, [isMobile, viewMode])
|
||||
|
||||
const { messages, sendMessage, status, setMessages, stop } = useChat({
|
||||
id: currentChatId ?? undefined,
|
||||
|
|
|
|||
217
apps/web/components/new/document-cards/notion-preview.tsx
Normal file
217
apps/web/components/new/document-cards/notion-preview.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { NotionDoc, SyncLogoIcon } from "@ui/assets/icons"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
// Extract structured blocks from markdown content for a visual preview
|
||||
function parseContentBlocks(content: string) {
|
||||
const lines = content.split("\n").filter((l) => l.trim())
|
||||
const blocks: {
|
||||
type: "heading" | "text" | "bullet" | "todo" | "divider"
|
||||
text: string
|
||||
checked?: boolean
|
||||
}[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (blocks.length >= 6) break
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed.startsWith("# ")) {
|
||||
blocks.push({ type: "heading", text: trimmed.replace(/^#+\s*/, "") })
|
||||
} else if (trimmed.startsWith("- [x]") || trimmed.startsWith("- [ ]")) {
|
||||
blocks.push({
|
||||
type: "todo",
|
||||
text: trimmed.replace(/^- \[.\]\s*/, ""),
|
||||
checked: trimmed.includes("[x]"),
|
||||
})
|
||||
} else if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
||||
blocks.push({ type: "bullet", text: trimmed.replace(/^[-*]\s*/, "") })
|
||||
} else if (trimmed === "---") {
|
||||
blocks.push({ type: "divider", text: "" })
|
||||
} else if (trimmed.length > 0) {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: trimmed.replace(/\*\*/g, "").replace(/\*/g, "").replace(/`/g, ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function NotionPreview({
|
||||
document,
|
||||
}: {
|
||||
document: DocumentWithMemories
|
||||
}) {
|
||||
const blocks = useMemo(
|
||||
() => parseContentBlocks(document.content || document.summary || ""),
|
||||
[document.content, document.summary],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-[#0B1017] rounded-[18px] overflow-hidden">
|
||||
{/* Header with Notion branding */}
|
||||
<div className="px-3 pt-3 pb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NotionDoc className="size-[14px]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] tracking-wide text-[#929292] uppercase",
|
||||
)}
|
||||
>
|
||||
Notion
|
||||
</span>
|
||||
</div>
|
||||
{/* Decorative dots mimicking Notion's block handles */}
|
||||
<div className="flex gap-[3px]">
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-[#333]" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-[#333]" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-[#333]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area — structured block preview */}
|
||||
<div className="px-3 pb-3 space-y-[5px]">
|
||||
{document.title && (
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] font-semibold text-[#E5E5E5] line-clamp-2 leading-[140%]",
|
||||
)}
|
||||
>
|
||||
{document.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{blocks.length > 0 ? (
|
||||
<div className="space-y-[3px]">
|
||||
{blocks.map((block, i) => {
|
||||
if (block.type === "divider") {
|
||||
return <div key={i} className="h-px bg-[#1F2937] my-1" />
|
||||
}
|
||||
|
||||
if (block.type === "heading") {
|
||||
return (
|
||||
<p
|
||||
key={i}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[11px] font-semibold text-[#B0B0B0] line-clamp-1",
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === "todo") {
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-[2px] w-[10px] h-[10px] rounded-[2px] border shrink-0",
|
||||
block.checked
|
||||
? "bg-[#2F81F7] border-[#2F81F7]"
|
||||
: "border-[#3B3B3B] bg-transparent",
|
||||
)}
|
||||
>
|
||||
{block.checked && (
|
||||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
className="w-full h-full text-white"
|
||||
>
|
||||
<path
|
||||
d="M2.5 5L4.5 7L7.5 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[10px] line-clamp-1 leading-[140%]",
|
||||
block.checked
|
||||
? "text-[#555] line-through"
|
||||
: "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === "bullet") {
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<div className="mt-[5px] w-[4px] h-[4px] rounded-full bg-[#555] shrink-0" />
|
||||
<p className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]">
|
||||
{block.text}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
key={i}
|
||||
className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]"
|
||||
>
|
||||
{block.text}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : document.summary ? (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
{document.summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{(document.memoryEntries.length > 0 || document.createdAt) && (
|
||||
<div className="px-3 pb-2.5 flex items-center justify-between">
|
||||
{document.memoryEntries.length > 0 && (
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-semibold flex items-center gap-1",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<SyncLogoIcon className="w-[12.33px] h-[10px]" />
|
||||
{document.memoryEntries.length}
|
||||
</p>
|
||||
)}
|
||||
<p className={cn(dmSansClassName(), "text-[10px] text-[#737373]")}>
|
||||
{new Date(document.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@ui/components/tabs"
|
||||
import { MemoryGraph } from "../memory-graph"
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string
|
||||
|
|
@ -166,8 +167,10 @@ function VersionStatus({
|
|||
|
||||
export function GraphListMemories({
|
||||
memoryEntries,
|
||||
documentId,
|
||||
}: {
|
||||
memoryEntries: MemoryEntry[]
|
||||
documentId?: string
|
||||
}) {
|
||||
const [expandedMemories, setExpandedMemories] = useState<Set<string>>(
|
||||
new Set(),
|
||||
|
|
@ -195,7 +198,7 @@ export function GraphListMemories({
|
|||
backgroundSize: "369px 371px",
|
||||
}}
|
||||
>
|
||||
<Tabs defaultValue="list">
|
||||
<Tabs defaultValue="list" className="flex flex-col flex-1 min-h-0">
|
||||
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
|
||||
<TabsTrigger
|
||||
value="graph"
|
||||
|
|
@ -257,90 +260,104 @@ export function GraphListMemories({
|
|||
</p>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-2 gap-2 pt-3 overflow-y-auto pr-1 scrollbar-thin items-start">
|
||||
{memoryEntries.map((memory, idx) => {
|
||||
const isClickable =
|
||||
memory.url &&
|
||||
(memory.url.startsWith("http://") ||
|
||||
memory.url.startsWith("https://"))
|
||||
<TabsContent value="graph" className="flex-1 min-h-0 mt-3">
|
||||
<div className="w-full h-full rounded-lg overflow-hidden">
|
||||
<MemoryGraph
|
||||
documentIds={documentId ? [documentId] : undefined}
|
||||
variant="consumer"
|
||||
maxNodes={50}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="list"
|
||||
className="flex-1 min-h-0 overflow-hidden mt-0"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 pt-3 overflow-y-auto pr-1 scrollbar-thin items-start h-full">
|
||||
{memoryEntries.map((memory, idx) => {
|
||||
const isClickable =
|
||||
memory.url &&
|
||||
(memory.url.startsWith("http://") ||
|
||||
memory.url.startsWith("https://"))
|
||||
|
||||
const status = memory.isForgotten
|
||||
? "forgotten"
|
||||
: memory.forgetAfter
|
||||
? "expiring"
|
||||
: memory.isStatic
|
||||
? "static"
|
||||
: "latest"
|
||||
const status = memory.isForgotten
|
||||
? "forgotten"
|
||||
: memory.forgetAfter
|
||||
? "expiring"
|
||||
: memory.isStatic
|
||||
? "static"
|
||||
: "latest"
|
||||
|
||||
const content = (
|
||||
<div className="">
|
||||
<div className="bg-[#060D17] p-2 px-[10px] rounded-[10px] m-[2px]">
|
||||
{memory.title && (
|
||||
<div className="text-xs text-[#525D6E] line-clamp-2">
|
||||
{memory.title}
|
||||
</div>
|
||||
)}
|
||||
{memory.memory && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-xs text-[#525D6E] cursor-pointer transition-all text-left w-full",
|
||||
expandedMemories.has(memory.id) ? "" : "line-clamp-2",
|
||||
const content = (
|
||||
<div className="">
|
||||
<div className="bg-[#060D17] p-2 px-[10px] rounded-[10px] m-[2px]">
|
||||
{memory.title && (
|
||||
<div className="text-xs text-[#525D6E] line-clamp-2">
|
||||
{memory.title}
|
||||
</div>
|
||||
)}
|
||||
{memory.memory && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-xs text-[#525D6E] cursor-pointer transition-all text-left w-full",
|
||||
expandedMemories.has(memory.id) ? "" : "line-clamp-2",
|
||||
)}
|
||||
onClick={() => toggleMemory(memory.id)}
|
||||
>
|
||||
{memory.memory}
|
||||
</button>
|
||||
)}
|
||||
{memory.url && (
|
||||
<div className="text-xs text-[#525D6E] truncate">
|
||||
{memory.url}
|
||||
</div>
|
||||
)}
|
||||
onClick={() => toggleMemory(memory.id)}
|
||||
>
|
||||
{memory.memory}
|
||||
</button>
|
||||
)}
|
||||
{memory.url && (
|
||||
<div className="text-xs text-[#525D6E] truncate">
|
||||
{memory.url}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-[10px] py-1 flex items-center justify-between">
|
||||
<div
|
||||
className="text-[10px] inline-block bg-clip-text text-transparent font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
v{memory.version}
|
||||
<div className="px-[10px] py-1 flex items-center justify-between">
|
||||
<div
|
||||
className="text-[10px] inline-block bg-clip-text text-transparent font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
v{memory.version}
|
||||
</div>
|
||||
<VersionStatus status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<VersionStatus status={status} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<a
|
||||
className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer self-start"
|
||||
href={memory.url}
|
||||
key={memory.id || idx}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
if (isClickable) {
|
||||
return (
|
||||
<a
|
||||
className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer self-start"
|
||||
href={memory.url}
|
||||
key={memory.id || idx}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-[#0C1829] rounded-xl self-start")}
|
||||
key={memory.id || idx}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-[#0C1829] rounded-xl self-start")}
|
||||
key={memory.id || idx}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ export function DocumentModal({
|
|||
{_document?.memoryEntries && _document.memoryEntries.length > 0 && (
|
||||
<GraphListMemories
|
||||
memoryEntries={_document.memoryEntries as MemoryEntry[]}
|
||||
documentId={_document.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
77
apps/web/components/new/graph-layout-view.tsx
Normal file
77
apps/web/components/new/graph-layout-view.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useState, useCallback, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import { MemoryGraph } from "./memory-graph/memory-graph"
|
||||
import { useProject } from "@/stores"
|
||||
import { useGraphHighlights } from "@/stores/highlights"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { ShareModal } from "./share-modal"
|
||||
|
||||
interface GraphLayoutViewProps {
|
||||
isChatOpen: boolean
|
||||
}
|
||||
|
||||
export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
|
||||
const { selectedProject } = useProject()
|
||||
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
const containerTags = selectedProject ? [selectedProject] : undefined
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
setIsShareModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseShareModal = useCallback(() => {
|
||||
setIsShareModalOpen(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[calc(100vh-86px)]">
|
||||
{/* Full-width graph */}
|
||||
<div className="absolute inset-0">
|
||||
<MemoryGraph
|
||||
containerTags={containerTags}
|
||||
variant="consumer"
|
||||
highlightDocumentIds={allHighlightDocumentIds}
|
||||
highlightsVisible={isChatOpen}
|
||||
maxNodes={200}
|
||||
canvasRef={canvasRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Share graph button - top left */}
|
||||
<div className="absolute top-4 left-4 z-15">
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full text-base gap-2 h-10!",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={handleShare}
|
||||
>
|
||||
<Image
|
||||
src="/icons/share-graph.svg"
|
||||
alt="Share"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Share graph
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Share modal */}
|
||||
<ShareModal
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={handleCloseShareModal}
|
||||
graphCanvasRef={canvasRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
GraphLayoutView.displayName = "GraphLayoutView"
|
||||
|
|
@ -38,6 +38,7 @@ import { useIsMobile } from "@hooks/use-mobile"
|
|||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { useState } from "react"
|
||||
import { FeedbackModal } from "./feedback-modal"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
|
||||
interface HeaderProps {
|
||||
onAddMemory?: () => void
|
||||
|
|
@ -59,6 +60,7 @@ export function Header({
|
|||
const isMobile = useIsMobile()
|
||||
const { resetOrgOnboarded } = useOrgOnboarding()
|
||||
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false)
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
|
||||
const handleTryOnboarding = () => {
|
||||
resetOrgOnboarded()
|
||||
|
|
@ -151,7 +153,10 @@ export function Header({
|
|||
)}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<Tabs defaultValue="grid">
|
||||
<Tabs
|
||||
value={viewMode === "list" ? "grid" : "graph"}
|
||||
onValueChange={(v) => setViewMode(v === "grid" ? "list" : "graph")}
|
||||
>
|
||||
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
|
||||
<TabsTrigger
|
||||
value="grid"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ interface HighlightsCardProps {
|
|||
onChat: (seed: string) => void
|
||||
onShowRelated: (query: string) => void
|
||||
isLoading?: boolean
|
||||
width?: number
|
||||
}
|
||||
|
||||
function renderContent(content: string, format: HighlightFormat) {
|
||||
|
|
@ -68,7 +67,6 @@ export function HighlightsCard({
|
|||
onChat,
|
||||
onShowRelated,
|
||||
isLoading = false,
|
||||
width = 216,
|
||||
}: HighlightsCardProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
|
|
@ -108,7 +106,6 @@ export function HighlightsCard({
|
|||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] items-center justify-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin text-[#4BA0FA]" />
|
||||
<span className="text-[10px] text-[#737373]">
|
||||
|
|
@ -125,7 +122,6 @@ export function HighlightsCard({
|
|||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -155,7 +151,6 @@ export function HighlightsCard({
|
|||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
<div id="highlights-header" className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import { YoutubePreview } from "./document-cards/youtube-preview"
|
|||
import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
|
||||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { McpPreview } from "./document-cards/mcp-preview"
|
||||
import { NotionPreview } from "./document-cards/notion-preview"
|
||||
import { getFaviconUrl } from "@/lib/url-helpers"
|
||||
import { QuickNoteCard } from "./quick-note-card"
|
||||
import { HighlightsCard, type HighlightItem } from "./highlights-card"
|
||||
import { GraphCard } from "./memory-graph"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
||||
// Document category type
|
||||
|
|
@ -62,11 +64,7 @@ const PAGE_SIZE = IS_DEV ? 100 : 100
|
|||
const MAX_TOTAL = 1000
|
||||
|
||||
// Discriminated union for masonry items
|
||||
type MasonryItem =
|
||||
| { type: "quick-note"; id: string }
|
||||
| { type: "highlights-card"; id: string }
|
||||
| { type: "highlights-card-spacer"; id: string }
|
||||
| { type: "document"; id: string; data: DocumentWithMemories }
|
||||
type MasonryItem = { type: "document"; id: string; data: DocumentWithMemories }
|
||||
|
||||
interface QuickNoteProps {
|
||||
onSave: (content: string) => void
|
||||
|
|
@ -193,32 +191,18 @@ export function MemoriesGrid({
|
|||
const masonryItems: MasonryItem[] = useMemo(() => {
|
||||
const items: MasonryItem[] = []
|
||||
|
||||
if (!isMobile) {
|
||||
if (hasQuickNote) {
|
||||
items.push({ type: "quick-note", id: "quick-note" })
|
||||
}
|
||||
if (hasHighlights) {
|
||||
items.push({ type: "highlights-card", id: "highlights-card" })
|
||||
// Add spacer to occupy the second column space for the 2-column highlights card
|
||||
items.push({
|
||||
type: "highlights-card-spacer",
|
||||
id: "highlights-card-spacer",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const doc of documents) {
|
||||
items.push({ type: "document", id: doc.id, data: doc })
|
||||
}
|
||||
|
||||
return items
|
||||
}, [documents, isMobile, hasQuickNote, hasHighlights])
|
||||
}, [documents])
|
||||
|
||||
// Stable key for Masonry based on document IDs, not item values
|
||||
const masonryKey = useMemo(() => {
|
||||
const docIds = documents.map((d) => d.id).join(",")
|
||||
return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}-${hasHighlights}`
|
||||
}, [documents, isChatOpen, hasQuickNote, hasHighlights])
|
||||
return `masonry-${documents.length}-${docIds}-${isChatOpen}`
|
||||
}, [documents, isChatOpen])
|
||||
|
||||
const isLoadingMore = isFetchingNextPage
|
||||
|
||||
|
|
@ -260,37 +244,6 @@ export function MemoriesGrid({
|
|||
data: MasonryItem
|
||||
width: number
|
||||
}) => {
|
||||
if (data.type === "quick-note" && quickNoteProps) {
|
||||
return (
|
||||
<div className="p-2" style={{ width }}>
|
||||
<QuickNoteCard {...quickNoteProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.type === "highlights-card" && highlightsProps) {
|
||||
const doubleWidth = width * 2
|
||||
const cardWidth = doubleWidth - 16
|
||||
return (
|
||||
<div className="p-2" style={{ width: doubleWidth }}>
|
||||
<HighlightsCard {...highlightsProps} width={cardWidth} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.type === "highlights-card-spacer") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height: 220, // Approximate height of HighlightsCard
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.type === "document") {
|
||||
return (
|
||||
<DocumentCard
|
||||
|
|
@ -304,7 +257,7 @@ export function MemoriesGrid({
|
|||
|
||||
return null
|
||||
},
|
||||
[handleCardClick, quickNoteProps, highlightsProps],
|
||||
[handleCardClick],
|
||||
)
|
||||
|
||||
if (!user) {
|
||||
|
|
@ -368,6 +321,29 @@ export function MemoriesGrid({
|
|||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto scrollbar-thin">
|
||||
{!isMobile && (hasQuickNote || hasHighlights) && (
|
||||
<div className="flex gap-2 mb-2 px-2">
|
||||
{hasQuickNote && quickNoteProps && (
|
||||
<div className="w-[216px] shrink-0">
|
||||
<QuickNoteCard {...quickNoteProps} />
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && highlightsProps && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<HighlightsCard {...highlightsProps} />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-[216px] shrink-0">
|
||||
<GraphCard
|
||||
containerTags={
|
||||
selectedProject ? [selectedProject] : undefined
|
||||
}
|
||||
width={200}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Masonry
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
|
|
@ -524,6 +500,7 @@ const DocumentCard = memo(
|
|||
<ContentPreview document={document} ogData={ogData} />
|
||||
{!(
|
||||
document.type === "image" ||
|
||||
document.type === "notion_doc" ||
|
||||
document.metadata?.mimeType?.toString().startsWith("image/")
|
||||
) && (
|
||||
<div className="pb-[10px] space-y-1">
|
||||
|
|
@ -568,7 +545,7 @@ const DocumentCard = memo(
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-[#369BFD] line-clamp-1 font-semibold flex items-center gap-1",
|
||||
"text-[10px] text-[#369BFD] font-semibold flex items-center gap-1",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
|
|
@ -579,10 +556,7 @@ const DocumentCard = memo(
|
|||
}}
|
||||
>
|
||||
<SyncLogoIcon className="w-[12.33px] h-[10px]" />
|
||||
{document.memoryEntries.length}{" "}
|
||||
{document.memoryEntries.length === 1
|
||||
? "memory"
|
||||
: "memories"}
|
||||
{document.memoryEntries.length}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
|
|
@ -644,6 +618,10 @@ function ContentPreview({
|
|||
return <YoutubePreview document={document} />
|
||||
}
|
||||
|
||||
if (document.type === "notion_doc") {
|
||||
return <NotionPreview document={document} />
|
||||
}
|
||||
|
||||
if (
|
||||
document.type === "pdf" ||
|
||||
document.type === "image" ||
|
||||
|
|
|
|||
79
apps/web/components/new/memory-graph/api-types.ts
Normal file
79
apps/web/components/new/memory-graph/api-types.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Standalone TypeScript types for Memory Graph
|
||||
// These mirror the API response types from @repo/validation/api
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string
|
||||
customId?: string | null
|
||||
documentId: string
|
||||
content: string | null
|
||||
summary?: string | null
|
||||
title?: string | null
|
||||
url?: string | null
|
||||
type?: string | null
|
||||
metadata?: Record<string, string | number | boolean> | null
|
||||
embedding?: number[] | null
|
||||
embeddingModel?: string | null
|
||||
tokenCount?: number | null
|
||||
createdAt: string | Date
|
||||
updatedAt: string | Date
|
||||
// Fields from join relationship
|
||||
sourceAddedAt?: Date | null
|
||||
sourceRelevanceScore?: number | null
|
||||
sourceMetadata?: Record<string, unknown> | null
|
||||
spaceContainerTag?: string | null
|
||||
// Version chain fields
|
||||
updatesMemoryId?: string | null
|
||||
nextVersionId?: string | null
|
||||
relation?: "updates" | "extends" | "derives" | null
|
||||
// Memory status fields
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: Date | string | null
|
||||
isLatest?: boolean
|
||||
// Space/container fields
|
||||
spaceId?: string | null
|
||||
// Legacy fields
|
||||
memory?: string | null
|
||||
memoryRelations?: Array<{
|
||||
relationType: "updates" | "extends" | "derives"
|
||||
targetMemoryId: string
|
||||
}> | null
|
||||
parentMemoryId?: string | null
|
||||
}
|
||||
|
||||
export interface DocumentWithMemories {
|
||||
id: string
|
||||
customId?: string | null
|
||||
contentHash: string | null
|
||||
orgId: string
|
||||
userId: string
|
||||
connectionId?: string | null
|
||||
title?: string | null
|
||||
content?: string | null
|
||||
summary?: string | null
|
||||
url?: string | null
|
||||
source?: string | null
|
||||
type?: string | null
|
||||
status: "pending" | "processing" | "done" | "failed"
|
||||
metadata?: Record<string, string | number | boolean> | null
|
||||
processingMetadata?: Record<string, unknown> | null
|
||||
raw?: string | null
|
||||
tokenCount?: number | null
|
||||
wordCount?: number | null
|
||||
chunkCount?: number | null
|
||||
averageChunkSize?: number | null
|
||||
summaryEmbedding?: number[] | null
|
||||
summaryEmbeddingModel?: string | null
|
||||
createdAt: string | Date
|
||||
updatedAt: string | Date
|
||||
memoryEntries: MemoryEntry[]
|
||||
}
|
||||
|
||||
export interface DocumentsResponse {
|
||||
documents: DocumentWithMemories[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
70
apps/web/components/new/memory-graph/canvas/hit-test.ts
Normal file
70
apps/web/components/new/memory-graph/canvas/hit-test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { GraphNode } from "../types"
|
||||
|
||||
export class SpatialIndex {
|
||||
private grid = new Map<string, GraphNode[]>()
|
||||
private cellSize = 200
|
||||
private lastHash = 0
|
||||
|
||||
rebuild(nodes: GraphNode[]): boolean {
|
||||
const hash = this.computeHash(nodes)
|
||||
if (hash === this.lastHash) return false
|
||||
this.lastHash = hash
|
||||
this.grid.clear()
|
||||
|
||||
for (const node of nodes) {
|
||||
const key = `${Math.floor(node.x / this.cellSize)},${Math.floor(node.y / this.cellSize)}`
|
||||
let cell = this.grid.get(key)
|
||||
if (!cell) {
|
||||
cell = []
|
||||
this.grid.set(key, cell)
|
||||
}
|
||||
cell.push(node)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
queryPoint(worldX: number, worldY: number): GraphNode | null {
|
||||
const cx = Math.floor(worldX / this.cellSize)
|
||||
const cy = Math.floor(worldY / this.cellSize)
|
||||
|
||||
// Check current cell + 8 neighbors
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const cell = this.grid.get(`${cx + dx},${cy + dy}`)
|
||||
if (!cell) continue
|
||||
|
||||
for (let i = cell.length - 1; i >= 0; i--) {
|
||||
const node = cell[i]!
|
||||
if (this.hitTest(node, worldX, worldY)) return node
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private hitTest(node: GraphNode, wx: number, wy: number): boolean {
|
||||
const halfSize = node.size * 0.5
|
||||
|
||||
if (node.type === "document") {
|
||||
// AABB rectangle hit test (50x50 node)
|
||||
return (
|
||||
Math.abs(wx - node.x) <= halfSize && Math.abs(wy - node.y) <= halfSize
|
||||
)
|
||||
}
|
||||
|
||||
// Circular hit test for hexagon memory nodes
|
||||
const dx = wx - node.x
|
||||
const dy = wy - node.y
|
||||
return dx * dx + dy * dy <= halfSize * halfSize
|
||||
}
|
||||
|
||||
private computeHash(nodes: GraphNode[]): number {
|
||||
let hash = nodes.length
|
||||
for (const n of nodes) {
|
||||
// Round to nearest integer to avoid false rebuilds from tiny physics jitter
|
||||
hash = (hash * 31 + (Math.round(n.x) | 0)) | 0
|
||||
hash = (hash * 31 + (Math.round(n.y) | 0)) | 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
}
|
||||
324
apps/web/components/new/memory-graph/canvas/input-handler.ts
Normal file
324
apps/web/components/new/memory-graph/canvas/input-handler.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import type { ViewportState } from "./viewport"
|
||||
import type { SpatialIndex } from "./hit-test"
|
||||
import type { GraphNode } from "../types"
|
||||
|
||||
interface InputCallbacks {
|
||||
onNodeHover: (id: string | null) => void
|
||||
onNodeClick: (id: string | null) => void
|
||||
onNodeDragStart: (id: string, node: GraphNode) => void
|
||||
onNodeDragEnd: () => void
|
||||
onRequestRender: () => void
|
||||
}
|
||||
|
||||
export class InputHandler {
|
||||
private canvas: HTMLCanvasElement
|
||||
private viewport: ViewportState
|
||||
private spatialIndex: SpatialIndex
|
||||
private callbacks: InputCallbacks
|
||||
|
||||
private isPanning = false
|
||||
private lastMouseX = 0
|
||||
private lastMouseY = 0
|
||||
|
||||
// Ring buffer for velocity tracking
|
||||
private posHistory: Array<{ x: number; y: number; t: number }> = []
|
||||
|
||||
private draggingNode: GraphNode | null = null
|
||||
private dragStartX = 0
|
||||
private dragStartY = 0
|
||||
private didDrag = false
|
||||
|
||||
private currentHoveredId: string | null = null
|
||||
|
||||
// Touch state
|
||||
private lastTouchDistance = 0
|
||||
private lastTouchCenter = { x: 0, y: 0 }
|
||||
private isTouchGesture = false
|
||||
|
||||
// Bound handlers for cleanup
|
||||
private boundMouseDown: (e: MouseEvent) => void
|
||||
private boundMouseMove: (e: MouseEvent) => void
|
||||
private boundMouseUp: (e: MouseEvent) => void
|
||||
private boundWheel: (e: WheelEvent) => void
|
||||
private boundClick: (e: MouseEvent) => void
|
||||
private boundDblClick: (e: MouseEvent) => void
|
||||
private boundTouchStart: (e: TouchEvent) => void
|
||||
private boundTouchMove: (e: TouchEvent) => void
|
||||
private boundTouchEnd: (e: TouchEvent) => void
|
||||
private boundGesture: (e: Event) => void
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
viewport: ViewportState,
|
||||
spatialIndex: SpatialIndex,
|
||||
callbacks: InputCallbacks,
|
||||
) {
|
||||
this.canvas = canvas
|
||||
this.viewport = viewport
|
||||
this.spatialIndex = spatialIndex
|
||||
this.callbacks = callbacks
|
||||
|
||||
this.boundMouseDown = this.onMouseDown.bind(this)
|
||||
this.boundMouseMove = this.onMouseMove.bind(this)
|
||||
this.boundMouseUp = this.onMouseUp.bind(this)
|
||||
this.boundWheel = this.onWheel.bind(this)
|
||||
this.boundClick = this.onClick.bind(this)
|
||||
this.boundDblClick = this.onDblClick.bind(this)
|
||||
this.boundTouchStart = this.onTouchStart.bind(this)
|
||||
this.boundTouchMove = this.onTouchMove.bind(this)
|
||||
this.boundTouchEnd = this.onTouchEnd.bind(this)
|
||||
this.boundGesture = (e: Event) => e.preventDefault()
|
||||
|
||||
canvas.addEventListener("mousedown", this.boundMouseDown)
|
||||
canvas.addEventListener("mousemove", this.boundMouseMove)
|
||||
canvas.addEventListener("mouseup", this.boundMouseUp)
|
||||
canvas.addEventListener("click", this.boundClick)
|
||||
canvas.addEventListener("dblclick", this.boundDblClick)
|
||||
canvas.addEventListener("wheel", this.boundWheel, { passive: false })
|
||||
canvas.addEventListener("touchstart", this.boundTouchStart, {
|
||||
passive: false,
|
||||
})
|
||||
canvas.addEventListener("touchmove", this.boundTouchMove, {
|
||||
passive: false,
|
||||
})
|
||||
canvas.addEventListener("touchend", this.boundTouchEnd)
|
||||
canvas.addEventListener("gesturestart", this.boundGesture, {
|
||||
passive: false,
|
||||
})
|
||||
canvas.addEventListener("gesturechange", this.boundGesture, {
|
||||
passive: false,
|
||||
})
|
||||
canvas.addEventListener("gestureend", this.boundGesture, { passive: false })
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
const c = this.canvas
|
||||
c.removeEventListener("mousedown", this.boundMouseDown)
|
||||
c.removeEventListener("mousemove", this.boundMouseMove)
|
||||
c.removeEventListener("mouseup", this.boundMouseUp)
|
||||
c.removeEventListener("click", this.boundClick)
|
||||
c.removeEventListener("dblclick", this.boundDblClick)
|
||||
c.removeEventListener("wheel", this.boundWheel)
|
||||
c.removeEventListener("touchstart", this.boundTouchStart)
|
||||
c.removeEventListener("touchmove", this.boundTouchMove)
|
||||
c.removeEventListener("touchend", this.boundTouchEnd)
|
||||
c.removeEventListener("gesturestart", this.boundGesture)
|
||||
c.removeEventListener("gesturechange", this.boundGesture)
|
||||
c.removeEventListener("gestureend", this.boundGesture)
|
||||
}
|
||||
|
||||
getDraggingNode(): GraphNode | null {
|
||||
return this.draggingNode
|
||||
}
|
||||
|
||||
private canvasXY(e: MouseEvent): { x: number; y: number } {
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
}
|
||||
|
||||
private onMouseDown(e: MouseEvent): void {
|
||||
const { x, y } = this.canvasXY(e)
|
||||
const world = this.viewport.screenToWorld(x, y)
|
||||
const node = this.spatialIndex.queryPoint(world.x, world.y)
|
||||
|
||||
this.lastMouseX = x
|
||||
this.lastMouseY = y
|
||||
this.posHistory = [{ x, y, t: performance.now() }]
|
||||
this.didDrag = false
|
||||
|
||||
if (node) {
|
||||
this.draggingNode = node
|
||||
this.dragStartX = x
|
||||
this.dragStartY = y
|
||||
node.fx = node.x
|
||||
node.fy = node.y
|
||||
this.callbacks.onNodeDragStart(node.id, node)
|
||||
this.canvas.style.cursor = "grabbing"
|
||||
} else {
|
||||
this.isPanning = true
|
||||
this.canvas.style.cursor = "grabbing"
|
||||
}
|
||||
}
|
||||
|
||||
private onMouseMove(e: MouseEvent): void {
|
||||
const { x, y } = this.canvasXY(e)
|
||||
|
||||
if (this.draggingNode) {
|
||||
const world = this.viewport.screenToWorld(x, y)
|
||||
this.draggingNode.fx = world.x
|
||||
this.draggingNode.fy = world.y
|
||||
this.draggingNode.x = world.x
|
||||
this.draggingNode.y = world.y
|
||||
this.didDrag = true
|
||||
this.callbacks.onRequestRender()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isPanning) {
|
||||
const dx = x - this.lastMouseX
|
||||
const dy = y - this.lastMouseY
|
||||
this.viewport.pan(dx, dy)
|
||||
this.lastMouseX = x
|
||||
this.lastMouseY = y
|
||||
this.didDrag = true
|
||||
|
||||
// Track positions for velocity (keep last 4)
|
||||
const now = performance.now()
|
||||
this.posHistory.push({ x, y, t: now })
|
||||
if (this.posHistory.length > 4) this.posHistory.shift()
|
||||
|
||||
this.callbacks.onRequestRender()
|
||||
return
|
||||
}
|
||||
|
||||
// Hover detection
|
||||
const world = this.viewport.screenToWorld(x, y)
|
||||
const node = this.spatialIndex.queryPoint(world.x, world.y)
|
||||
const id = node?.id ?? null
|
||||
if (id !== this.currentHoveredId) {
|
||||
this.currentHoveredId = id
|
||||
this.callbacks.onNodeHover(id)
|
||||
this.canvas.style.cursor = id ? "grab" : "default"
|
||||
this.callbacks.onRequestRender()
|
||||
}
|
||||
}
|
||||
|
||||
private onMouseUp(_e: MouseEvent): void {
|
||||
if (this.draggingNode) {
|
||||
this.draggingNode.fx = null
|
||||
this.draggingNode.fy = null
|
||||
this.draggingNode = null
|
||||
this.callbacks.onNodeDragEnd()
|
||||
this.canvas.style.cursor = this.currentHoveredId ? "grab" : "default"
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isPanning) {
|
||||
this.isPanning = false
|
||||
|
||||
// Calculate release velocity from position history
|
||||
if (this.posHistory.length >= 2) {
|
||||
const newest = this.posHistory[this.posHistory.length - 1]!
|
||||
const oldest = this.posHistory[0]!
|
||||
const dt = newest.t - oldest.t
|
||||
if (dt > 0 && dt < 200) {
|
||||
const vx = ((newest.x - oldest.x) / dt) * 16 // scale to ~60fps frame
|
||||
const vy = ((newest.y - oldest.y) / dt) * 16
|
||||
this.viewport.releaseWithVelocity(vx, vy)
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas.style.cursor = "default"
|
||||
this.callbacks.onRequestRender()
|
||||
}
|
||||
}
|
||||
|
||||
private onClick(e: MouseEvent): void {
|
||||
if (this.didDrag) return
|
||||
|
||||
const { x, y } = this.canvasXY(e)
|
||||
const world = this.viewport.screenToWorld(x, y)
|
||||
const node = this.spatialIndex.queryPoint(world.x, world.y)
|
||||
this.callbacks.onNodeClick(node?.id ?? null)
|
||||
}
|
||||
|
||||
private onDblClick(e: MouseEvent): void {
|
||||
const { x, y } = this.canvasXY(e)
|
||||
this.viewport.zoomTo(this.viewport.zoom * 1.5, x, y)
|
||||
this.callbacks.onRequestRender()
|
||||
}
|
||||
|
||||
private onWheel(e: WheelEvent): void {
|
||||
e.preventDefault()
|
||||
|
||||
const { x, y } = this.canvasXY(e)
|
||||
|
||||
// Horizontal scroll -> pan
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
this.viewport.pan(-e.deltaX, 0)
|
||||
this.callbacks.onRequestRender()
|
||||
return
|
||||
}
|
||||
|
||||
// Vertical scroll -> zoom
|
||||
const factor = e.deltaY > 0 ? 0.97 : 1.03
|
||||
this.viewport.zoomImmediate(factor, x, y)
|
||||
this.callbacks.onRequestRender()
|
||||
}
|
||||
|
||||
// Touch handling
|
||||
private onTouchStart(e: TouchEvent): void {
|
||||
e.preventDefault()
|
||||
const touches = e.touches
|
||||
|
||||
if (touches.length >= 2) {
|
||||
this.isTouchGesture = true
|
||||
const t0 = touches[0]!
|
||||
const t1 = touches[1]!
|
||||
this.lastTouchDistance = Math.hypot(
|
||||
t1.clientX - t0.clientX,
|
||||
t1.clientY - t0.clientY,
|
||||
)
|
||||
this.lastTouchCenter = {
|
||||
x: (t0.clientX + t1.clientX) / 2,
|
||||
y: (t0.clientY + t1.clientY) / 2,
|
||||
}
|
||||
} else if (touches.length === 1) {
|
||||
this.isTouchGesture = false
|
||||
const t = touches[0]!
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
this.lastMouseX = t.clientX - rect.left
|
||||
this.lastMouseY = t.clientY - rect.top
|
||||
this.isPanning = true
|
||||
}
|
||||
}
|
||||
|
||||
private onTouchMove(e: TouchEvent): void {
|
||||
e.preventDefault()
|
||||
const touches = e.touches
|
||||
|
||||
if (touches.length >= 2 && this.isTouchGesture) {
|
||||
const t0 = touches[0]!
|
||||
const t1 = touches[1]!
|
||||
const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY)
|
||||
const center = {
|
||||
x: (t0.clientX + t1.clientX) / 2,
|
||||
y: (t0.clientY + t1.clientY) / 2,
|
||||
}
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
const cx = center.x - rect.left
|
||||
const cy = center.y - rect.top
|
||||
|
||||
// Pinch zoom
|
||||
const scale = dist / this.lastTouchDistance
|
||||
this.viewport.zoomImmediate(scale, cx, cy)
|
||||
|
||||
// Pan from center movement
|
||||
const dx = center.x - this.lastTouchCenter.x
|
||||
const dy = center.y - this.lastTouchCenter.y
|
||||
this.viewport.pan(dx, dy)
|
||||
|
||||
this.lastTouchDistance = dist
|
||||
this.lastTouchCenter = center
|
||||
this.callbacks.onRequestRender()
|
||||
} else if (touches.length === 1 && this.isPanning && !this.isTouchGesture) {
|
||||
const t = touches[0]!
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
const x = t.clientX - rect.left
|
||||
const y = t.clientY - rect.top
|
||||
this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY)
|
||||
this.lastMouseX = x
|
||||
this.lastMouseY = y
|
||||
this.callbacks.onRequestRender()
|
||||
}
|
||||
}
|
||||
|
||||
private onTouchEnd(e: TouchEvent): void {
|
||||
if (e.touches.length < 2) {
|
||||
this.isTouchGesture = false
|
||||
}
|
||||
if (e.touches.length === 0) {
|
||||
this.isPanning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
681
apps/web/components/new/memory-graph/canvas/renderer.ts
Normal file
681
apps/web/components/new/memory-graph/canvas/renderer.ts
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
import type { ViewportState } from "./viewport"
|
||||
import type { GraphNode, GraphEdge, DocumentNodeData } from "../types"
|
||||
|
||||
export interface RenderState {
|
||||
selectedNodeId: string | null
|
||||
hoveredNodeId: string | null
|
||||
highlightIds: Set<string>
|
||||
dimProgress: number
|
||||
}
|
||||
|
||||
export function renderFrame(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
state: RenderState,
|
||||
nodeMap: Map<string, GraphNode>,
|
||||
): void {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
drawDocDocLines(ctx, nodes, viewport, width, height)
|
||||
drawEdges(ctx, edges, viewport, width, height, state, nodeMap)
|
||||
drawNodes(ctx, nodes, viewport, width, height, state)
|
||||
}
|
||||
|
||||
// Connect each visible doc to its 2 nearest neighbors
|
||||
function drawDocDocLines(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: GraphNode[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
const docs: { x: number; y: number }[] = []
|
||||
for (const n of nodes) {
|
||||
if (n.type !== "document") continue
|
||||
const s = viewport.worldToScreen(n.x, n.y)
|
||||
if (s.x > -100 && s.x < width + 100 && s.y > -100 && s.y < height + 100) {
|
||||
docs.push(s)
|
||||
}
|
||||
}
|
||||
if (docs.length < 2) return
|
||||
|
||||
ctx.strokeStyle = "#8DA3F4"
|
||||
ctx.lineWidth = 1
|
||||
ctx.globalAlpha = 0.3
|
||||
ctx.setLineDash([4, 6])
|
||||
ctx.beginPath()
|
||||
|
||||
// Deduplicate: only draw line when i < neighbor index
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const d = docs[i]!
|
||||
let best1 = -1
|
||||
let best2 = -1
|
||||
let dist1 = Number.POSITIVE_INFINITY
|
||||
let dist2 = Number.POSITIVE_INFINITY
|
||||
|
||||
for (let j = 0; j < docs.length; j++) {
|
||||
if (j === i) continue
|
||||
const dx = docs[j]!.x - d.x
|
||||
const dy = docs[j]!.y - d.y
|
||||
const dist = dx * dx + dy * dy
|
||||
if (dist < dist1) {
|
||||
best2 = best1
|
||||
dist2 = dist1
|
||||
best1 = j
|
||||
dist1 = dist
|
||||
} else if (dist < dist2) {
|
||||
best2 = j
|
||||
dist2 = dist
|
||||
}
|
||||
}
|
||||
|
||||
if (best1 >= 0 && i < best1) {
|
||||
ctx.moveTo(d.x, d.y)
|
||||
ctx.lineTo(docs[best1]!.x, docs[best1]!.y)
|
||||
}
|
||||
if (best2 >= 0 && i < best2) {
|
||||
ctx.moveTo(d.x, d.y)
|
||||
ctx.lineTo(docs[best2]!.x, docs[best2]!.y)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
// --- Edges ---
|
||||
|
||||
const EDGE_STYLE: Record<string, { color: string; width: number }> = {
|
||||
"doc-memory": { color: "#4A5568", width: 1.5 },
|
||||
version: { color: "#8B5CF6", width: 2 },
|
||||
}
|
||||
|
||||
const SIM_STRONG = { color: "#00D4B8", width: 2 } as const
|
||||
const SIM_MEDIUM = { color: "#6B8FBF", width: 1.5 } as const
|
||||
const SIM_WEAK = { color: "#4A6A8A", width: 1 } as const
|
||||
|
||||
function edgeStyle(edge: GraphEdge): { color: string; width: number } {
|
||||
const preset = EDGE_STYLE[edge.edgeType]
|
||||
if (preset) return preset
|
||||
if (edge.similarity >= 0.9) return SIM_STRONG
|
||||
if (edge.similarity >= 0.8) return SIM_MEDIUM
|
||||
return SIM_WEAK
|
||||
}
|
||||
|
||||
// Unique key for batching: "color|width"
|
||||
function batchKey(style: { color: string; width: number }): string {
|
||||
return `${style.color}|${style.width}`
|
||||
}
|
||||
|
||||
interface PreparedEdge {
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
connected: boolean
|
||||
style: { color: string; width: number }
|
||||
isVersion: boolean
|
||||
arrowSize: number
|
||||
}
|
||||
|
||||
function drawEdges(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
edges: GraphEdge[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
state: RenderState,
|
||||
nodeMap: Map<string, GraphNode>,
|
||||
): void {
|
||||
const margin = 100
|
||||
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
|
||||
|
||||
// Prepare all visible edges
|
||||
const prepared: PreparedEdge[] = []
|
||||
|
||||
for (const edge of edges) {
|
||||
const src =
|
||||
typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source
|
||||
const tgt =
|
||||
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
|
||||
if (!src || !tgt) continue
|
||||
|
||||
// Skip doc-memory edges when memory dots are too small to see connections
|
||||
if (edge.edgeType === "doc-memory") {
|
||||
const mem = src.type === "memory" ? src : tgt
|
||||
if (mem.size * viewport.zoom < 3) continue
|
||||
}
|
||||
|
||||
const s = viewport.worldToScreen(src.x, src.y)
|
||||
const t = viewport.worldToScreen(tgt.x, tgt.y)
|
||||
|
||||
if (
|
||||
(s.x < -margin && t.x < -margin) ||
|
||||
(s.x > width + margin && t.x > width + margin) ||
|
||||
(s.y < -margin && t.y < -margin) ||
|
||||
(s.y > height + margin && t.y > height + margin)
|
||||
)
|
||||
continue
|
||||
|
||||
const dx = t.x - s.x
|
||||
const dy = t.y - s.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < 1) continue
|
||||
|
||||
const ux = dx / dist
|
||||
const uy = dy / dist
|
||||
const sr = src.size * viewport.zoom * 0.5
|
||||
const tr = tgt.size * viewport.zoom * 0.5
|
||||
|
||||
let connected = true
|
||||
if (hasDim) {
|
||||
const srcId =
|
||||
typeof edge.source === "string" ? edge.source : edge.source.id
|
||||
const tgtId =
|
||||
typeof edge.target === "string" ? edge.target : edge.target.id
|
||||
connected =
|
||||
srcId === state.selectedNodeId || tgtId === state.selectedNodeId
|
||||
}
|
||||
|
||||
prepared.push({
|
||||
startX: s.x + ux * sr,
|
||||
startY: s.y + uy * sr,
|
||||
endX: t.x - ux * tr,
|
||||
endY: t.y - uy * tr,
|
||||
connected,
|
||||
style: edgeStyle(edge),
|
||||
isVersion: edge.edgeType === "version",
|
||||
arrowSize:
|
||||
edge.edgeType === "version" ? Math.max(6, 8 * viewport.zoom) : 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Batch by style + dim state: group into "key|connected" and "key|dimmed"
|
||||
const batches = new Map<string, PreparedEdge[]>()
|
||||
for (const e of prepared) {
|
||||
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
|
||||
const key = batchKey(e.style) + dimKey
|
||||
let batch = batches.get(key)
|
||||
if (!batch) {
|
||||
batch = []
|
||||
batches.set(key, batch)
|
||||
}
|
||||
batch.push(e)
|
||||
}
|
||||
|
||||
// Draw each batch in a single beginPath/stroke
|
||||
ctx.setLineDash([])
|
||||
for (const [key, batch] of batches) {
|
||||
const first = batch[0]!
|
||||
const isDimmed = key.endsWith("|d")
|
||||
|
||||
ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1
|
||||
ctx.strokeStyle = first.style.color
|
||||
ctx.lineWidth = first.style.width
|
||||
|
||||
ctx.beginPath()
|
||||
for (const e of batch) {
|
||||
ctx.moveTo(e.startX, e.startY)
|
||||
ctx.lineTo(e.endX, e.endY)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Arrow heads for version edges (fill calls — unavoidable per-arrow)
|
||||
const versionEdges = batch.filter((e) => e.isVersion)
|
||||
if (versionEdges.length > 0) {
|
||||
ctx.fillStyle = first.style.color
|
||||
for (const e of versionEdges) {
|
||||
drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
function drawArrowHead(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
toX: number,
|
||||
toY: number,
|
||||
size: number,
|
||||
): void {
|
||||
const angle = Math.atan2(toY - fromY, toX - fromX)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(toX, toY)
|
||||
ctx.lineTo(
|
||||
toX - size * Math.cos(angle - Math.PI / 6),
|
||||
toY - size * Math.sin(angle - Math.PI / 6),
|
||||
)
|
||||
ctx.lineTo(
|
||||
toX - size * Math.cos(angle + Math.PI / 6),
|
||||
toY - size * Math.sin(angle + Math.PI / 6),
|
||||
)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
|
||||
function drawNodes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: GraphNode[],
|
||||
viewport: ViewportState,
|
||||
width: number,
|
||||
height: number,
|
||||
state: RenderState,
|
||||
): void {
|
||||
const margin = 60
|
||||
const memDots: { x: number; y: number; r: number; color: string }[] = []
|
||||
const docDots: { x: number; y: number; s: number }[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const screen = viewport.worldToScreen(node.x, node.y)
|
||||
const screenSize = node.size * viewport.zoom
|
||||
|
||||
// Frustum cull (use at least 2px so tiny nodes aren't culled)
|
||||
const cullSize = Math.max(screenSize, 2)
|
||||
if (
|
||||
screen.x + cullSize < -margin ||
|
||||
screen.x - cullSize > width + margin ||
|
||||
screen.y + cullSize < -margin ||
|
||||
screen.y - cullSize > height + margin
|
||||
)
|
||||
continue
|
||||
|
||||
const isSelected = node.id === state.selectedNodeId
|
||||
const isHovered = node.id === state.hoveredNodeId
|
||||
const isHighlighted = state.highlightIds.has(node.id)
|
||||
|
||||
// LOD: tiny nodes → batched dots (but selected/highlighted always get full detail)
|
||||
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
|
||||
if (node.type === "document") {
|
||||
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
|
||||
} else {
|
||||
memDots.push({
|
||||
x: screen.x,
|
||||
y: screen.y,
|
||||
r: Math.max(2, screenSize * 0.45),
|
||||
color: node.borderColor || "#3B73B8",
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let alpha = 1
|
||||
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
|
||||
alpha = 1 - state.dimProgress * 0.7
|
||||
}
|
||||
ctx.globalAlpha = alpha
|
||||
|
||||
if (node.type === "document") {
|
||||
drawDocumentNode(
|
||||
ctx,
|
||||
screen.x,
|
||||
screen.y,
|
||||
screenSize,
|
||||
node,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isHighlighted,
|
||||
)
|
||||
} else {
|
||||
drawMemoryNode(
|
||||
ctx,
|
||||
screen.x,
|
||||
screen.y,
|
||||
screenSize,
|
||||
node,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isHighlighted,
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected || isHighlighted) {
|
||||
drawGlow(ctx, screen.x, screen.y, screenSize, node.type)
|
||||
}
|
||||
}
|
||||
|
||||
const dimAlpha =
|
||||
state.selectedNodeId && state.dimProgress > 0
|
||||
? 1 - state.dimProgress * 0.7
|
||||
: 1
|
||||
|
||||
// Batch: document dots as filled squares
|
||||
if (docDots.length > 0) {
|
||||
ctx.fillStyle = "#1B1F24"
|
||||
ctx.strokeStyle = "#2A2F36"
|
||||
ctx.lineWidth = 1
|
||||
ctx.globalAlpha = dimAlpha
|
||||
for (const d of docDots) {
|
||||
const h = d.s * 0.5
|
||||
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
|
||||
ctx.strokeRect(d.x - h, d.y - h, d.s, d.s)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch: memory dots — dark fill, then colored border strokes
|
||||
if (memDots.length > 0) {
|
||||
ctx.globalAlpha = dimAlpha
|
||||
|
||||
// Pass 1: all dark fills in one batch
|
||||
ctx.fillStyle = "#0D2034"
|
||||
ctx.beginPath()
|
||||
for (const d of memDots) {
|
||||
ctx.moveTo(d.x + d.r, d.y)
|
||||
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
|
||||
}
|
||||
ctx.fill()
|
||||
|
||||
// Pass 2: colored strokes grouped by border color
|
||||
ctx.lineWidth = 1.5
|
||||
const byColor = new Map<string, typeof memDots>()
|
||||
for (const d of memDots) {
|
||||
let batch = byColor.get(d.color)
|
||||
if (!batch) {
|
||||
batch = []
|
||||
byColor.set(d.color, batch)
|
||||
}
|
||||
batch.push(d)
|
||||
}
|
||||
for (const [color, batch] of byColor) {
|
||||
ctx.strokeStyle = color
|
||||
ctx.beginPath()
|
||||
for (const d of batch) {
|
||||
ctx.moveTo(d.x + d.r, d.y)
|
||||
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
function drawDocumentNode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
sy: number,
|
||||
size: number,
|
||||
node: GraphNode,
|
||||
isSelected: boolean,
|
||||
isHovered: boolean,
|
||||
isHighlighted: boolean,
|
||||
): void {
|
||||
const half = size * 0.5
|
||||
const cornerR = 8 * (size / 50)
|
||||
|
||||
// Outer rect
|
||||
ctx.fillStyle = "#1B1F24"
|
||||
ctx.strokeStyle =
|
||||
isSelected || isHighlighted ? "#3B73B8" : isHovered ? "#3B73B8" : "#2A2F36"
|
||||
ctx.lineWidth = isSelected || isHighlighted ? 2 : 1
|
||||
roundRect(ctx, sx - half, sy - half, size, size, cornerR)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
// Inner rect
|
||||
const innerSize = size * 0.72
|
||||
const innerHalf = innerSize * 0.5
|
||||
const innerR = 6 * (size / 50)
|
||||
ctx.fillStyle = "#13161A"
|
||||
roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR)
|
||||
ctx.fill()
|
||||
|
||||
// Icon
|
||||
const iconSize = size * 0.35
|
||||
const docType =
|
||||
node.type === "document" ? (node.data as DocumentNodeData).type : "text"
|
||||
drawDocIcon(ctx, sx, sy, iconSize, docType || "text")
|
||||
}
|
||||
|
||||
function drawMemoryNode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
sy: number,
|
||||
size: number,
|
||||
node: GraphNode,
|
||||
isSelected: boolean,
|
||||
isHovered: boolean,
|
||||
_isHighlighted: boolean,
|
||||
): void {
|
||||
const radius = size * 0.5
|
||||
|
||||
// Fill
|
||||
ctx.fillStyle = isHovered ? "#112840" : "#0D2034"
|
||||
drawHexagon(ctx, sx, sy, radius)
|
||||
ctx.fill()
|
||||
|
||||
// Stroke with time-based border color
|
||||
const borderColor = node.borderColor || "#3B73B8"
|
||||
ctx.strokeStyle = isSelected ? "#3B73B8" : borderColor
|
||||
ctx.lineWidth = isHovered ? 2 : 1.5
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawGlow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
sy: number,
|
||||
size: number,
|
||||
nodeType: "document" | "memory",
|
||||
): void {
|
||||
ctx.strokeStyle = "#3B73B8"
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash([3, 3])
|
||||
ctx.globalAlpha = 0.8
|
||||
|
||||
if (nodeType === "document") {
|
||||
const glowSize = size * 1.15
|
||||
const half = glowSize * 0.5
|
||||
const r = 8 * (glowSize / 50)
|
||||
roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r)
|
||||
} else {
|
||||
drawHexagon(ctx, sx, sy, size * 0.5 * 1.15)
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
// --- Shapes ---
|
||||
|
||||
function drawHexagon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
): void {
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i - Math.PI / 6
|
||||
const x = cx + radius * Math.cos(angle)
|
||||
const y = cy + radius * Math.sin(angle)
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function roundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
): void {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.lineTo(x + w - r, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + r, r)
|
||||
ctx.lineTo(x + w, y + h - r)
|
||||
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
|
||||
ctx.lineTo(x + r, y + h)
|
||||
ctx.arcTo(x, y + h, x, y + h - r, r)
|
||||
ctx.lineTo(x, y + r)
|
||||
ctx.arcTo(x, y, x + r, y, r)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
// --- Document icons ---
|
||||
|
||||
const ICON_COLOR = "#3B73B8"
|
||||
|
||||
function drawDocIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
type: string,
|
||||
): void {
|
||||
ctx.save()
|
||||
ctx.fillStyle = ICON_COLOR
|
||||
ctx.strokeStyle = ICON_COLOR
|
||||
ctx.lineWidth = Math.max(1, size / 12)
|
||||
ctx.lineCap = "round"
|
||||
ctx.lineJoin = "round"
|
||||
|
||||
switch (type) {
|
||||
case "webpage":
|
||||
case "url":
|
||||
drawGlobeIcon(ctx, x, y, size)
|
||||
break
|
||||
case "pdf":
|
||||
drawTextLabel(ctx, x, y, size, "PDF", 0.35)
|
||||
break
|
||||
case "md":
|
||||
case "markdown":
|
||||
drawTextLabel(ctx, x, y, size, "MD", 0.3)
|
||||
break
|
||||
case "doc":
|
||||
case "docx":
|
||||
drawTextLabel(ctx, x, y, size, "DOC", 0.28)
|
||||
break
|
||||
case "csv":
|
||||
drawGridIcon(ctx, x, y, size)
|
||||
break
|
||||
case "json":
|
||||
drawBracesIcon(ctx, x, y, size)
|
||||
break
|
||||
case "notion":
|
||||
case "notion_doc":
|
||||
drawTextLabel(ctx, x, y, size, "N", 0.4)
|
||||
break
|
||||
case "google_doc":
|
||||
case "google_sheet":
|
||||
case "google_slide":
|
||||
drawTextLabel(ctx, x, y, size, "G", 0.4)
|
||||
break
|
||||
default:
|
||||
drawDocOutline(ctx, x, y, size)
|
||||
break
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawTextLabel(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
text: string,
|
||||
fontRatio: number,
|
||||
): void {
|
||||
ctx.font = `bold ${size * fontRatio}px sans-serif`
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
function drawGlobeIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const r = size * 0.4
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - r, y)
|
||||
ctx.lineTo(x + r, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawGridIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.7
|
||||
const h = size * 0.7
|
||||
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y - h / 2)
|
||||
ctx.lineTo(x, y + h / 2)
|
||||
ctx.moveTo(x - w / 2, y)
|
||||
ctx.lineTo(x + w / 2, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawBracesIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.6
|
||||
const h = size * 0.8
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - w / 4, y - h / 2)
|
||||
ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y)
|
||||
ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + w / 4, y - h / 2)
|
||||
ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y)
|
||||
ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawDocOutline(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
): void {
|
||||
const w = size * 0.7
|
||||
const h = size * 0.85
|
||||
const fold = size * 0.2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - w / 2, y - h / 2)
|
||||
ctx.lineTo(x + w / 2 - fold, y - h / 2)
|
||||
ctx.lineTo(x + w / 2, y - h / 2 + fold)
|
||||
ctx.lineTo(x + w / 2, y + h / 2)
|
||||
ctx.lineTo(x - w / 2, y + h / 2)
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
const sp = size * 0.15
|
||||
const lw = size * 0.4
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - lw / 2, y - sp)
|
||||
ctx.lineTo(x + lw / 2, y - sp)
|
||||
ctx.moveTo(x - lw / 2, y)
|
||||
ctx.lineTo(x + lw / 2, y)
|
||||
ctx.moveTo(x - lw / 2, y + sp)
|
||||
ctx.lineTo(x + lw / 2, y + sp)
|
||||
ctx.stroke()
|
||||
}
|
||||
79
apps/web/components/new/memory-graph/canvas/simulation.ts
Normal file
79
apps/web/components/new/memory-graph/canvas/simulation.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import * as d3 from "d3-force"
|
||||
import type { GraphNode, GraphEdge } from "../types"
|
||||
|
||||
export class ForceSimulation {
|
||||
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
|
||||
|
||||
init(nodes: GraphNode[], edges: GraphEdge[]): void {
|
||||
this.destroy()
|
||||
|
||||
try {
|
||||
this.sim = d3
|
||||
.forceSimulation<GraphNode>(nodes)
|
||||
.alphaDecay(0.03)
|
||||
.alphaMin(0.001)
|
||||
.velocityDecay(0.6)
|
||||
|
||||
this.sim.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink<GraphNode, GraphEdge>(edges)
|
||||
.id((d) => d.id)
|
||||
.distance((link) => (link.edgeType === "doc-memory" ? 150 : 300))
|
||||
.strength((link) => {
|
||||
if (link.edgeType === "doc-memory") return 0.8
|
||||
if (link.edgeType === "version") return 1.0
|
||||
return link.similarity * 0.3
|
||||
}),
|
||||
)
|
||||
|
||||
this.sim.force("charge", d3.forceManyBody<GraphNode>().strength(-1000))
|
||||
|
||||
this.sim.force(
|
||||
"collide",
|
||||
d3
|
||||
.forceCollide<GraphNode>()
|
||||
.radius((d) => (d.type === "document" ? 80 : 40))
|
||||
.strength(0.7),
|
||||
)
|
||||
|
||||
this.sim.force("x", d3.forceX().strength(0.05))
|
||||
this.sim.force("y", d3.forceY().strength(0.05))
|
||||
|
||||
// Pre-settle synchronously, then start the live simulation
|
||||
this.sim.stop()
|
||||
this.sim.alpha(1)
|
||||
for (let i = 0; i < 50; i++) this.sim.tick()
|
||||
this.sim.alphaTarget(0).restart()
|
||||
} catch (e) {
|
||||
console.error("ForceSimulation.init failed:", e)
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
update(nodes: GraphNode[], edges: GraphEdge[]): void {
|
||||
if (!this.sim) return
|
||||
this.sim.nodes(nodes)
|
||||
const linkForce = this.sim.force<d3.ForceLink<GraphNode, GraphEdge>>("link")
|
||||
if (linkForce) linkForce.links(edges)
|
||||
}
|
||||
|
||||
reheat(): void {
|
||||
this.sim?.alphaTarget(0.3).restart()
|
||||
}
|
||||
|
||||
coolDown(): void {
|
||||
this.sim?.alphaTarget(0)
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return (this.sim?.alpha() ?? 0) > 0.001
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.sim) {
|
||||
this.sim.stop()
|
||||
this.sim = null
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/web/components/new/memory-graph/canvas/version-chain.ts
Normal file
63
apps/web/components/new/memory-graph/canvas/version-chain.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { GraphApiDocument, GraphApiMemory } from "../types"
|
||||
|
||||
export interface ChainEntry {
|
||||
id: string
|
||||
version: number
|
||||
memory: string
|
||||
isForgotten: boolean
|
||||
isLatest: boolean
|
||||
}
|
||||
|
||||
export class VersionChainIndex {
|
||||
private memoryMap = new Map<string, GraphApiMemory>()
|
||||
private cache = new Map<string, ChainEntry[]>()
|
||||
private lastDocs: GraphApiDocument[] | null = null
|
||||
|
||||
rebuild(documents: GraphApiDocument[]): void {
|
||||
if (documents === this.lastDocs) return
|
||||
this.lastDocs = documents
|
||||
this.memoryMap.clear()
|
||||
this.cache.clear()
|
||||
|
||||
for (const doc of documents) {
|
||||
for (const m of doc.memories) {
|
||||
this.memoryMap.set(m.id, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getChain(memoryId: string): ChainEntry[] | null {
|
||||
const cached = this.cache.get(memoryId)
|
||||
if (cached) return cached
|
||||
|
||||
const mem = this.memoryMap.get(memoryId)
|
||||
if (!mem || mem.version <= 1) return null
|
||||
|
||||
// Walk parentMemoryId up to the root
|
||||
const chain: ChainEntry[] = []
|
||||
const visited = new Set<string>()
|
||||
let current: GraphApiMemory | undefined = mem
|
||||
while (current && !visited.has(current.id)) {
|
||||
visited.add(current.id)
|
||||
chain.push({
|
||||
id: current.id,
|
||||
version: current.version,
|
||||
memory: current.memory,
|
||||
isForgotten: current.isForgotten,
|
||||
isLatest: current.isLatest,
|
||||
})
|
||||
current = current.parentMemoryId
|
||||
? this.memoryMap.get(current.parentMemoryId)
|
||||
: undefined
|
||||
}
|
||||
|
||||
chain.reverse()
|
||||
|
||||
// Cache for every member in the chain
|
||||
for (const entry of chain) {
|
||||
this.cache.set(entry.id, chain)
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
}
|
||||
172
apps/web/components/new/memory-graph/canvas/viewport.ts
Normal file
172
apps/web/components/new/memory-graph/canvas/viewport.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
export class ViewportState {
|
||||
panX: number
|
||||
panY: number
|
||||
zoom: number
|
||||
|
||||
private velocityX = 0
|
||||
private velocityY = 0
|
||||
private readonly friction = 0.92
|
||||
|
||||
private targetZoom: number
|
||||
private readonly zoomSpring = 0.15
|
||||
private zoomAnchorX = 0
|
||||
private zoomAnchorY = 0
|
||||
|
||||
private targetPanX: number | null = null
|
||||
private targetPanY: number | null = null
|
||||
private readonly panLerp = 0.12
|
||||
|
||||
private static readonly MIN_ZOOM = 0.1
|
||||
private static readonly MAX_ZOOM = 5.0
|
||||
|
||||
constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) {
|
||||
this.panX = initialPanX
|
||||
this.panY = initialPanY
|
||||
this.zoom = initialZoom
|
||||
this.targetZoom = initialZoom
|
||||
}
|
||||
|
||||
worldToScreen(wx: number, wy: number): { x: number; y: number } {
|
||||
return {
|
||||
x: wx * this.zoom + this.panX,
|
||||
y: wy * this.zoom + this.panY,
|
||||
}
|
||||
}
|
||||
|
||||
screenToWorld(sx: number, sy: number): { x: number; y: number } {
|
||||
return {
|
||||
x: (sx - this.panX) / this.zoom,
|
||||
y: (sy - this.panY) / this.zoom,
|
||||
}
|
||||
}
|
||||
|
||||
pan(dx: number, dy: number): void {
|
||||
this.panX += dx
|
||||
this.panY += dy
|
||||
// Cancel any target pan animation when user drags
|
||||
this.targetPanX = null
|
||||
this.targetPanY = null
|
||||
}
|
||||
|
||||
releaseWithVelocity(vx: number, vy: number): void {
|
||||
this.velocityX = vx
|
||||
this.velocityY = vy
|
||||
}
|
||||
|
||||
zoomImmediate(delta: number, anchorX: number, anchorY: number): void {
|
||||
const world = this.screenToWorld(anchorX, anchorY)
|
||||
this.zoom = clamp(
|
||||
this.zoom * delta,
|
||||
ViewportState.MIN_ZOOM,
|
||||
ViewportState.MAX_ZOOM,
|
||||
)
|
||||
this.targetZoom = this.zoom
|
||||
this.panX = anchorX - world.x * this.zoom
|
||||
this.panY = anchorY - world.y * this.zoom
|
||||
}
|
||||
|
||||
zoomTo(target: number, anchorX: number, anchorY: number): void {
|
||||
this.targetZoom = clamp(
|
||||
target,
|
||||
ViewportState.MIN_ZOOM,
|
||||
ViewportState.MAX_ZOOM,
|
||||
)
|
||||
this.zoomAnchorX = anchorX
|
||||
this.zoomAnchorY = anchorY
|
||||
}
|
||||
|
||||
fitToNodes(
|
||||
nodes: Array<{ x: number; y: number; size: number }>,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
if (nodes.length === 0) return
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const n of nodes) {
|
||||
minX = Math.min(minX, n.x - n.size)
|
||||
maxX = Math.max(maxX, n.x + n.size)
|
||||
minY = Math.min(minY, n.y - n.size)
|
||||
maxY = Math.max(maxY, n.y + n.size)
|
||||
}
|
||||
|
||||
const pad = 0.1
|
||||
const cw = (maxX - minX) * (1 + pad * 2)
|
||||
const ch = (maxY - minY) * (1 + pad * 2)
|
||||
const cx = (minX + maxX) / 2
|
||||
const cy = (minY + maxY) / 2
|
||||
|
||||
const fitZoom = Math.min(width / cw, height / ch, 1)
|
||||
this.targetZoom = clamp(
|
||||
fitZoom,
|
||||
ViewportState.MIN_ZOOM,
|
||||
ViewportState.MAX_ZOOM,
|
||||
)
|
||||
this.zoomAnchorX = width / 2
|
||||
this.zoomAnchorY = height / 2
|
||||
this.targetPanX = width / 2 - cx * this.targetZoom
|
||||
this.targetPanY = height / 2 - cy * this.targetZoom
|
||||
}
|
||||
|
||||
centerOn(
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
this.targetPanX = width / 2 - worldX * this.zoom
|
||||
this.targetPanY = height / 2 - worldY * this.zoom
|
||||
}
|
||||
|
||||
tick(): boolean {
|
||||
let moving = false
|
||||
|
||||
// Momentum panning
|
||||
if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) {
|
||||
this.panX += this.velocityX
|
||||
this.panY += this.velocityY
|
||||
this.velocityX *= this.friction
|
||||
this.velocityY *= this.friction
|
||||
moving = true
|
||||
} else {
|
||||
this.velocityX = 0
|
||||
this.velocityY = 0
|
||||
}
|
||||
|
||||
// Spring zoom
|
||||
const zoomDiff = this.targetZoom - this.zoom
|
||||
if (Math.abs(zoomDiff) > 0.001) {
|
||||
const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY)
|
||||
this.zoom += zoomDiff * this.zoomSpring
|
||||
this.panX = this.zoomAnchorX - world.x * this.zoom
|
||||
this.panY = this.zoomAnchorY - world.y * this.zoom
|
||||
moving = true
|
||||
}
|
||||
|
||||
// Lerp pan animation
|
||||
if (this.targetPanX !== null && this.targetPanY !== null) {
|
||||
const dx = this.targetPanX - this.panX
|
||||
const dy = this.targetPanY - this.panY
|
||||
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
||||
this.panX += dx * this.panLerp
|
||||
this.panY += dy * this.panLerp
|
||||
moving = true
|
||||
} else {
|
||||
this.panX = this.targetPanX
|
||||
this.panY = this.targetPanY
|
||||
this.targetPanX = null
|
||||
this.targetPanY = null
|
||||
}
|
||||
}
|
||||
|
||||
return moving
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return v < min ? min : v > max ? max : v
|
||||
}
|
||||
62
apps/web/components/new/memory-graph/constants.ts
Normal file
62
apps/web/components/new/memory-graph/constants.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export const colors = {
|
||||
background: {
|
||||
primary: "#0f1419",
|
||||
secondary: "#1a1f29",
|
||||
accent: "#252a35",
|
||||
},
|
||||
hexagon: {
|
||||
active: { fill: "#0D2034", stroke: "#3B73B8", strokeWidth: 1.68 },
|
||||
inactive: { fill: "#0B1826", stroke: "#3D4857", strokeWidth: 1.4 },
|
||||
hovered: { fill: "#112840", stroke: "#4A8AD0", strokeWidth: 2 },
|
||||
},
|
||||
document: {
|
||||
outer: { fill: "#1B1F24", stroke: "#2A2F36", radius: 8 },
|
||||
inner: { fill: "#13161A", radius: 6 },
|
||||
iconColor: "#3B73B8",
|
||||
},
|
||||
text: {
|
||||
primary: "#ffffff",
|
||||
secondary: "#e2e8f0",
|
||||
muted: "#94a3b8",
|
||||
},
|
||||
}
|
||||
|
||||
export const MEMORY_BORDER = {
|
||||
forgotten: "#EF4444",
|
||||
expiring: "#F59E0B",
|
||||
recent: "#10B981",
|
||||
default: "#3B73B8",
|
||||
} as const
|
||||
|
||||
export const EDGE_COLORS = {
|
||||
docMemory: "#4A5568",
|
||||
similarityStrong: "#00D4B8",
|
||||
similarityMedium: "#6B8FBF",
|
||||
similarityWeak: "#4A6A8A",
|
||||
version: "#8B5CF6",
|
||||
} as const
|
||||
|
||||
export const FORCE_CONFIG = {
|
||||
linkStrength: {
|
||||
docMemory: 0.8,
|
||||
version: 1.0,
|
||||
docDocBase: 0.3,
|
||||
},
|
||||
linkDistance: 300,
|
||||
docMemoryDistance: 150,
|
||||
chargeStrength: -1000,
|
||||
collisionRadius: { document: 80, memory: 40 },
|
||||
alphaDecay: 0.03,
|
||||
alphaMin: 0.001,
|
||||
velocityDecay: 0.6,
|
||||
alphaTarget: 0.3,
|
||||
}
|
||||
|
||||
export const GRAPH_SETTINGS = {
|
||||
console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 },
|
||||
consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 },
|
||||
}
|
||||
|
||||
export const ANIMATION = {
|
||||
dimDuration: 1500,
|
||||
}
|
||||
248
apps/web/components/new/memory-graph/graph-canvas.tsx
Normal file
248
apps/web/components/new/memory-graph/graph-canvas.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useEffect, useLayoutEffect, useRef } from "react"
|
||||
import type { GraphCanvasProps, GraphNode } from "./types"
|
||||
import { ViewportState } from "./canvas/viewport"
|
||||
import { SpatialIndex } from "./canvas/hit-test"
|
||||
import { InputHandler } from "./canvas/input-handler"
|
||||
import { renderFrame } from "./canvas/renderer"
|
||||
import { GRAPH_SETTINGS } from "./constants"
|
||||
|
||||
export const GraphCanvas = memo<GraphCanvasProps>(function GraphCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
width,
|
||||
height,
|
||||
highlightDocumentIds,
|
||||
selectedNodeId = null,
|
||||
onNodeHover,
|
||||
onNodeClick,
|
||||
onNodeDragStart,
|
||||
onNodeDragEnd,
|
||||
onViewportChange,
|
||||
canvasRef: externalCanvasRef,
|
||||
variant = "console",
|
||||
simulation,
|
||||
viewportRef: externalViewportRef,
|
||||
}) {
|
||||
const internalCanvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const canvasRef = externalCanvasRef || internalCanvasRef
|
||||
|
||||
// Engine instances — mutable, never trigger re-renders
|
||||
const viewportRef = useRef<ViewportState | null>(null)
|
||||
const spatialRef = useRef(new SpatialIndex())
|
||||
const inputRef = useRef<InputHandler | null>(null)
|
||||
const rafRef = useRef(0)
|
||||
const renderNeeded = useRef(true)
|
||||
const nodeMapRef = useRef(new Map<string, GraphNode>())
|
||||
|
||||
// All mutable render state in a single ref — the rAF loop reads from here
|
||||
const s = useRef({
|
||||
nodes,
|
||||
edges,
|
||||
width,
|
||||
height,
|
||||
selectedNodeId,
|
||||
hoveredNodeId: null as string | null,
|
||||
highlightIds: new Set(highlightDocumentIds ?? []),
|
||||
dimProgress: 0,
|
||||
dimTarget: selectedNodeId ? 1 : 0,
|
||||
})
|
||||
|
||||
// Sync incoming props to mutable state (no re-renders)
|
||||
s.current.nodes = nodes
|
||||
s.current.edges = edges
|
||||
s.current.width = width
|
||||
s.current.height = height
|
||||
|
||||
// Stable callback refs so InputHandler never needs recreation
|
||||
const cb = useRef({
|
||||
onNodeHover,
|
||||
onNodeClick,
|
||||
onNodeDragStart,
|
||||
onNodeDragEnd,
|
||||
onViewportChange,
|
||||
simulation,
|
||||
})
|
||||
cb.current = {
|
||||
onNodeHover,
|
||||
onNodeClick,
|
||||
onNodeDragStart,
|
||||
onNodeDragEnd,
|
||||
onViewportChange,
|
||||
simulation,
|
||||
}
|
||||
|
||||
// Rebuild nodeMap + spatial index when nodes change
|
||||
useEffect(() => {
|
||||
const map = nodeMapRef.current
|
||||
map.clear()
|
||||
for (const n of nodes) map.set(n.id, n)
|
||||
spatialRef.current.rebuild(nodes)
|
||||
renderNeeded.current = true
|
||||
}, [nodes])
|
||||
|
||||
useEffect(() => {
|
||||
s.current.highlightIds = new Set(highlightDocumentIds ?? [])
|
||||
renderNeeded.current = true
|
||||
}, [highlightDocumentIds])
|
||||
|
||||
useEffect(() => {
|
||||
s.current.selectedNodeId = selectedNodeId
|
||||
s.current.dimTarget = selectedNodeId ? 1 : 0
|
||||
renderNeeded.current = true
|
||||
}, [selectedNodeId])
|
||||
|
||||
// Create viewport + input handler (once per variant)
|
||||
useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const cfg = GRAPH_SETTINGS[variant]
|
||||
const vp = new ViewportState(
|
||||
cfg.initialPanX,
|
||||
cfg.initialPanY,
|
||||
cfg.initialZoom,
|
||||
)
|
||||
viewportRef.current = vp
|
||||
if (externalViewportRef) {
|
||||
;(
|
||||
externalViewportRef as React.MutableRefObject<ViewportState | null>
|
||||
).current = vp
|
||||
}
|
||||
|
||||
const handler = new InputHandler(canvas, vp, spatialRef.current, {
|
||||
onNodeHover: (id) => {
|
||||
s.current.hoveredNodeId = id
|
||||
cb.current.onNodeHover(id)
|
||||
renderNeeded.current = true
|
||||
},
|
||||
onNodeClick: (id) => cb.current.onNodeClick(id),
|
||||
onNodeDragStart: (id, node) => {
|
||||
cb.current.onNodeDragStart(id)
|
||||
cb.current.simulation?.reheat()
|
||||
},
|
||||
onNodeDragEnd: () => {
|
||||
cb.current.onNodeDragEnd()
|
||||
cb.current.simulation?.coolDown()
|
||||
},
|
||||
onRequestRender: () => {
|
||||
renderNeeded.current = true
|
||||
},
|
||||
})
|
||||
inputRef.current = handler
|
||||
|
||||
return () => handler.destroy()
|
||||
}, [variant])
|
||||
|
||||
// High-DPI canvas sizing
|
||||
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || width === 0 || height === 0) return
|
||||
|
||||
const MAX = 16384
|
||||
const d = Math.min(MAX / width, MAX / height, dpr)
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
canvas.width = Math.min(width * d, MAX)
|
||||
canvas.height = Math.min(height * d, MAX)
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (ctx) {
|
||||
ctx.scale(d, d)
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = "high"
|
||||
}
|
||||
renderNeeded.current = true
|
||||
}, [width, height, dpr])
|
||||
|
||||
// Single render loop — runs for component lifetime, reads everything from refs
|
||||
useEffect(() => {
|
||||
let lastReportedZoom = 0
|
||||
|
||||
const tick = () => {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
|
||||
const vp = viewportRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!vp || !canvas) return
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const cur = s.current
|
||||
|
||||
// 1. Viewport momentum / spring zoom / lerp pan
|
||||
const vpMoving = vp.tick()
|
||||
|
||||
// 2. Dim animation (ease toward target)
|
||||
const dd = cur.dimTarget - cur.dimProgress
|
||||
let dimming = false
|
||||
if (Math.abs(dd) > 0.01) {
|
||||
cur.dimProgress += dd * 0.1
|
||||
dimming = true
|
||||
} else {
|
||||
cur.dimProgress = cur.dimTarget
|
||||
}
|
||||
|
||||
// 3. Simulation physics
|
||||
const simActive = cb.current.simulation?.isActive() ?? false
|
||||
|
||||
// 4. Spatial index rebuild (only when positions actually move)
|
||||
const spatialChanged =
|
||||
simActive || inputRef.current?.getDraggingNode()
|
||||
? spatialRef.current.rebuild(cur.nodes)
|
||||
: false
|
||||
|
||||
// Skip frame if nothing changed
|
||||
if (
|
||||
!vpMoving &&
|
||||
!simActive &&
|
||||
!dimming &&
|
||||
!spatialChanged &&
|
||||
!renderNeeded.current
|
||||
)
|
||||
return
|
||||
renderNeeded.current = false
|
||||
|
||||
// Throttled zoom reporting for NavigationControls
|
||||
if (
|
||||
vpMoving &&
|
||||
cb.current.onViewportChange &&
|
||||
Math.abs(vp.zoom - lastReportedZoom) > 0.005
|
||||
) {
|
||||
lastReportedZoom = vp.zoom
|
||||
cb.current.onViewportChange(vp.zoom)
|
||||
}
|
||||
|
||||
renderFrame(
|
||||
ctx,
|
||||
cur.nodes,
|
||||
cur.edges,
|
||||
vp,
|
||||
cur.width,
|
||||
cur.height,
|
||||
{
|
||||
selectedNodeId: cur.selectedNodeId,
|
||||
hoveredNodeId: cur.hoveredNodeId,
|
||||
highlightIds: cur.highlightIds,
|
||||
dimProgress: cur.dimProgress,
|
||||
},
|
||||
nodeMapRef.current,
|
||||
)
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0"
|
||||
style={{ touchAction: "none", userSelect: "none" }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
202
apps/web/components/new/memory-graph/graph-card.tsx
Normal file
202
apps/web/components/new/memory-graph/graph-card.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useMemo } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { Expand } from "lucide-react"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
|
||||
export interface GraphCardProps {
|
||||
containerTags?: string[]
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Simple seeded random for deterministic node positions
|
||||
function seededRandom(seed: number) {
|
||||
let s = seed
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647
|
||||
return s / 2147483647
|
||||
}
|
||||
}
|
||||
|
||||
function StaticGraphPreview({
|
||||
documentCount,
|
||||
memoryCount,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
documentCount: number
|
||||
memoryCount: number
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const nodes = useMemo(() => {
|
||||
const rand = seededRandom(42)
|
||||
const count = Math.min(documentCount + memoryCount, 30)
|
||||
const docCount = Math.min(documentCount, 12)
|
||||
const result: {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
color: string
|
||||
opacity: number
|
||||
}[] = []
|
||||
|
||||
const pad = 20
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isDoc = i < docCount
|
||||
result.push({
|
||||
x: pad + rand() * (width - pad * 2),
|
||||
y: pad + rand() * (height - pad * 2),
|
||||
r: isDoc ? 4 + rand() * 3 : 2 + rand() * 2,
|
||||
color: isDoc ? "#4BA0FA" : "#36FDFD",
|
||||
opacity: 0.4 + rand() * 0.4,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [documentCount, memoryCount, width, height])
|
||||
|
||||
const edges = useMemo(() => {
|
||||
if (nodes.length < 2) return []
|
||||
const rand = seededRandom(123)
|
||||
const result: { x1: number; y1: number; x2: number; y2: number }[] = []
|
||||
const edgeCount = Math.min(nodes.length - 1, 20)
|
||||
for (let i = 0; i < edgeCount; i++) {
|
||||
const a = Math.floor(rand() * nodes.length)
|
||||
let b = Math.floor(rand() * nodes.length)
|
||||
if (b === a) b = (a + 1) % nodes.length
|
||||
result.push({
|
||||
x1: nodes[a]!.x,
|
||||
y1: nodes[a]!.y,
|
||||
x2: nodes[b]!.x,
|
||||
y2: nodes[b]!.y,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [nodes])
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className="absolute inset-0"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
{edges.map((e, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={e.x1}
|
||||
y1={e.y1}
|
||||
x2={e.x2}
|
||||
y2={e.y2}
|
||||
stroke="#4BA0FA"
|
||||
strokeOpacity={0.15}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
{nodes.map((n, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={n.r}
|
||||
fill={n.color}
|
||||
opacity={n.opacity}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const GraphCard = memo<GraphCardProps>(
|
||||
({ containerTags, width = 216, height = 220, className }) => {
|
||||
const { setViewMode } = useViewMode()
|
||||
|
||||
const { data, isLoading, error } = useGraphApi({
|
||||
containerTags,
|
||||
limit: 20,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col items-center justify-center",
|
||||
dmSansClassName(),
|
||||
className,
|
||||
)}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<p className="text-[10px] text-red-400 text-center">
|
||||
Failed to load graph
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const documentCount = data.stats?.documentsWithSpatial ?? 0
|
||||
const memoryCount = data.documents.reduce(
|
||||
(sum, d) => sum + d.memories.length,
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("graph")}
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col cursor-pointer transition-all hover:border-[rgba(255,255,255,0.1)] hover:bg-[#0f1419] group relative overflow-hidden",
|
||||
dmSansClassName(),
|
||||
className,
|
||||
)}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="flex-1 w-full relative overflow-hidden rounded-lg">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : documentCount > 0 || memoryCount > 0 ? (
|
||||
<StaticGraphPreview
|
||||
documentCount={documentCount}
|
||||
memoryCount={memoryCount}
|
||||
width={width - 24}
|
||||
height={height - 56}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="text-[10px] text-[#737373] text-center">
|
||||
No documents yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/30 transition-colors">
|
||||
<Expand className="w-5 h-5 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[#737373]">
|
||||
{documentCount} docs
|
||||
</span>
|
||||
<span className="text-[10px] text-[#4BA0FA]">
|
||||
{memoryCount} memories
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#737373] group-hover:text-white transition-colors">
|
||||
View graph
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
GraphCard.displayName = "GraphCard"
|
||||
273
apps/web/components/new/memory-graph/hooks/use-graph-api.ts
Normal file
273
apps/web/components/new/memory-graph/hooks/use-graph-api.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useCallback, useMemo, useState, useRef, useEffect } from "react"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import type {
|
||||
GraphViewportResponse,
|
||||
GraphBoundsResponse,
|
||||
GraphStatsResponse,
|
||||
} from "../types"
|
||||
|
||||
interface ViewportParams {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
|
||||
interface UseGraphApiOptions {
|
||||
containerTags?: string[]
|
||||
limit?: number
|
||||
enabled?: boolean
|
||||
documentIds?: string[]
|
||||
}
|
||||
|
||||
export function useGraphApi(options: UseGraphApiOptions = {}) {
|
||||
const { containerTags, documentIds, limit = 200, 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", {
|
||||
body: {
|
||||
viewport: {
|
||||
minX: viewport.minX,
|
||||
maxX: viewport.maxX,
|
||||
minY: viewport.minY,
|
||||
maxY: viewport.maxY,
|
||||
},
|
||||
containerTags,
|
||||
documentIds,
|
||||
limit,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to fetch graph viewport",
|
||||
)
|
||||
}
|
||||
|
||||
return response.data as GraphViewportResponse
|
||||
},
|
||||
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 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
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
289
apps/web/components/new/memory-graph/hooks/use-graph-data.ts
Normal file
289
apps/web/components/new/memory-graph/hooks/use-graph-data.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useRef, useEffect } from "react"
|
||||
import { MEMORY_BORDER, EDGE_COLORS } from "../constants"
|
||||
import type {
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
GraphApiEdge,
|
||||
DocumentNodeData,
|
||||
MemoryNodeData,
|
||||
} from "../types"
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const MEMORY_CLUSTER_SPREAD = 150
|
||||
|
||||
function getMemoryBorderColor(mem: GraphApiMemory): string {
|
||||
if (mem.isForgotten) return MEMORY_BORDER.forgotten
|
||||
if (mem.forgetAfter) {
|
||||
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
|
||||
if (msLeft < SEVEN_DAYS_MS) return MEMORY_BORDER.expiring
|
||||
}
|
||||
const age = Date.now() - new Date(mem.createdAt).getTime()
|
||||
if (age < ONE_DAY_MS) return MEMORY_BORDER.recent
|
||||
return MEMORY_BORDER.default
|
||||
}
|
||||
|
||||
function getEdgeVisualProps(similarity: number) {
|
||||
return {
|
||||
opacity: 0.3 + similarity * 0.5,
|
||||
thickness: 1 + similarity * 1.5,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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),
|
||||
}))
|
||||
}
|
||||
|
||||
export function useGraphData(
|
||||
documents: GraphApiDocument[],
|
||||
apiEdges: GraphApiEdge[],
|
||||
draggingNodeId: string | null,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
) {
|
||||
const nodeCache = useRef<Map<string, GraphNode>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!documents || documents.length === 0) return
|
||||
|
||||
const currentIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
currentIds.add(doc.id)
|
||||
for (const mem of doc.memories) currentIds.add(mem.id)
|
||||
}
|
||||
|
||||
for (const [id] of nodeCache.current.entries()) {
|
||||
if (!currentIds.has(id)) nodeCache.current.delete(id)
|
||||
}
|
||||
}, [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 []
|
||||
|
||||
const result: GraphNode[] = []
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
const initialX = doc.x * scale + offsetX
|
||||
const initialY = doc.y * scale + offsetY
|
||||
|
||||
let docNode = nodeCache.current.get(doc.id)
|
||||
const docData: DocumentNodeData = {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary,
|
||||
type: doc.documentType,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memories,
|
||||
}
|
||||
|
||||
if (docNode) {
|
||||
docNode.data = docData
|
||||
docNode.isDragging = draggingNodeId === doc.id
|
||||
} else {
|
||||
docNode = {
|
||||
id: doc.id,
|
||||
type: "document",
|
||||
x: initialX,
|
||||
y: initialY,
|
||||
data: docData,
|
||||
size: 50,
|
||||
borderColor: "#2A2F36",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
}
|
||||
nodeCache.current.set(doc.id, docNode)
|
||||
}
|
||||
result.push(docNode)
|
||||
|
||||
const memCount = doc.memories.length
|
||||
for (let i = 0; i < memCount; i++) {
|
||||
const mem = doc.memories[i]!
|
||||
let memNode = nodeCache.current.get(mem.id)
|
||||
const memData: MemoryNodeData = {
|
||||
...mem,
|
||||
documentId: doc.id,
|
||||
content: mem.memory,
|
||||
}
|
||||
|
||||
if (memNode) {
|
||||
memNode.data = memData
|
||||
memNode.borderColor = getMemoryBorderColor(mem)
|
||||
memNode.isDragging = draggingNodeId === mem.id
|
||||
} else {
|
||||
const angle = (i / memCount) * 2 * Math.PI
|
||||
memNode = {
|
||||
id: mem.id,
|
||||
type: "memory",
|
||||
x: docNode.x + Math.cos(angle) * MEMORY_CLUSTER_SPREAD,
|
||||
y: docNode.y + Math.sin(angle) * MEMORY_CLUSTER_SPREAD,
|
||||
data: memData,
|
||||
size: 36,
|
||||
borderColor: getMemoryBorderColor(mem),
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
}
|
||||
nodeCache.current.set(mem.id, memNode)
|
||||
}
|
||||
result.push(memNode)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId])
|
||||
|
||||
const edges = useMemo(() => {
|
||||
if (!normalizedDocs || normalizedDocs.length === 0) return []
|
||||
|
||||
const result: GraphEdge[] = []
|
||||
const allNodeIds = new Set(nodes.map((n) => n.id))
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
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 },
|
||||
edgeType: "doc-memory",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
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 },
|
||||
edgeType: "version",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const apiEdge of apiEdges) {
|
||||
if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) {
|
||||
continue
|
||||
}
|
||||
|
||||
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, nodes])
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
33
apps/web/components/new/memory-graph/index.ts
Normal file
33
apps/web/components/new/memory-graph/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Memory Graph components
|
||||
export { MemoryGraph } from "./memory-graph"
|
||||
export type { MemoryGraphProps } from "./memory-graph"
|
||||
export { GraphCard } from "./graph-card"
|
||||
export type { GraphCardProps } from "./graph-card"
|
||||
|
||||
// Hooks
|
||||
export { useGraphApi } from "./hooks/use-graph-api"
|
||||
export {
|
||||
useGraphData,
|
||||
calculateBackendViewport,
|
||||
screenToBackendCoords,
|
||||
} from "./hooks/use-graph-data"
|
||||
|
||||
// Canvas engine
|
||||
export { ViewportState } from "./canvas/viewport"
|
||||
export { ForceSimulation } from "./canvas/simulation"
|
||||
|
||||
// Types
|
||||
export type {
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphApiDocument,
|
||||
GraphApiMemory,
|
||||
GraphApiEdge,
|
||||
GraphViewportResponse,
|
||||
GraphBoundsResponse,
|
||||
GraphStatsResponse,
|
||||
DocumentNodeData,
|
||||
MemoryNodeData,
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
} from "./types"
|
||||
599
apps/web/components/new/memory-graph/legend.tsx
Normal file
599
apps/web/components/new/memory-graph/legend.tsx
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
"use client"
|
||||
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@ui/components/collapsible"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import type { GraphEdge, GraphNode, LegendProps } from "./types"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
// Cookie utility functions for legend state
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === "undefined") return
|
||||
const expires = new Date()
|
||||
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
|
||||
}
|
||||
|
||||
const getCookie = (name: string): string | null => {
|
||||
if (typeof document === "undefined") return null
|
||||
const nameEQ = `${name}=`
|
||||
const ca = document.cookie.split(";")
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i]
|
||||
if (!c) continue
|
||||
while (c.charAt(0) === " ") c = c.substring(1, c.length)
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface ExtendedLegendProps extends LegendProps {
|
||||
id?: string
|
||||
nodes?: GraphNode[]
|
||||
edges?: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
// Toggle switch component matching Figma design
|
||||
const SmallToggle = memo(function SmallToggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"box-border flex flex-row justify-center items-center",
|
||||
"w-6 h-3.5 rounded-full transition-all duration-200",
|
||||
"border border-white/5",
|
||||
"shadow-[inset_1px_1px_2px_rgba(0,0,0,0.5)]",
|
||||
)}
|
||||
style={{
|
||||
background: "#0D121A",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-2.5 h-2.5 rounded-full transition-all duration-200",
|
||||
checked ? "ml-auto mr-0.5" : "mr-auto ml-0.5",
|
||||
)}
|
||||
style={{
|
||||
background: checked ? "#162E57" : "rgba(115, 115, 115, 0.25)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
// Hexagon SVG for memory nodes
|
||||
const HexagonIcon = memo(function HexagonIcon({
|
||||
fill = "#0D2034",
|
||||
stroke = "#3B73B8",
|
||||
opacity = 1,
|
||||
size = 12,
|
||||
}: {
|
||||
fill?: string
|
||||
stroke?: string
|
||||
opacity?: number
|
||||
size?: number
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 12 12"
|
||||
style={{ opacity }}
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75"
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth="0.6"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
// Document icon (rounded square)
|
||||
const DocumentIcon = memo(function DocumentIcon() {
|
||||
return (
|
||||
<div
|
||||
className="w-3 h-3 shrink-0 rounded-[2.4px] flex items-center justify-center"
|
||||
style={{
|
||||
background: "#1B1F24",
|
||||
boxShadow:
|
||||
"0px 0.85px 4.26px rgba(0, 0, 0, 0.25), inset 0.21px 0.21px 0.21px rgba(255, 255, 255, 0.1)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[10.8px] h-[10.8px] rounded-[1.8px]"
|
||||
style={{
|
||||
background: "#262C33",
|
||||
boxShadow: "inset 0.43px 0.43px 1.28px rgba(11, 15, 21, 0.4)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Connection icon (graph)
|
||||
const ConnectionIcon = memo(function ConnectionIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="3" cy="3" r="1.5" fill="#90A2B9" />
|
||||
<circle cx="9" cy="3" r="1.5" fill="#90A2B9" />
|
||||
<circle cx="6" cy="9" r="1.5" fill="#90A2B9" />
|
||||
<line x1="3" y1="3" x2="9" y2="3" stroke="#90A2B9" strokeWidth="0.8" />
|
||||
<line x1="3" y1="3" x2="6" y2="9" stroke="#90A2B9" strokeWidth="0.8" />
|
||||
<line x1="9" y1="3" x2="6" y2="9" stroke="#90A2B9" strokeWidth="0.8" />
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
// Line icon for connections
|
||||
const LineIcon = memo(function LineIcon({
|
||||
color,
|
||||
dashed = false,
|
||||
}: {
|
||||
color: string
|
||||
dashed?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="w-3 h-3 flex items-center justify-center shrink-0">
|
||||
<div
|
||||
className="w-3 h-0"
|
||||
style={{
|
||||
borderTop: `1.6px ${dashed ? "dashed" : "solid"} ${color}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Similarity circle icon
|
||||
const SimilarityCircle = memo(function SimilarityCircle({
|
||||
variant,
|
||||
}: {
|
||||
variant: "strong" | "weak"
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{
|
||||
background: variant === "strong" ? "#616D7F" : "#313A44",
|
||||
border: "0.6px solid rgba(255, 255, 255, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
// Accordion row with count
|
||||
const StatRow = memo(function StatRow({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
expandable = false,
|
||||
expanded = false,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
count: number
|
||||
expandable?: boolean
|
||||
expanded?: boolean
|
||||
onToggle?: () => void
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
className={cn(
|
||||
"flex flex-row justify-between items-center w-full py-0",
|
||||
expandable && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-xs text-[#FAFAFA] font-normal">{label}</span>
|
||||
{expandable && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-3 h-3 text-[#737373] transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">{count}</span>
|
||||
</button>
|
||||
{expandable && expanded && children && (
|
||||
<div className="pl-2.5 pt-1.5 flex flex-col gap-1.5">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Toggle row for relations/similarity
|
||||
const ToggleRow = memo(function ToggleRow({
|
||||
icon,
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-xs text-[#FAFAFA] font-normal">{label}</span>
|
||||
</div>
|
||||
<SmallToggle checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Legend = memo(function Legend({
|
||||
variant: _variant = "console",
|
||||
id,
|
||||
nodes = [],
|
||||
edges = [],
|
||||
isLoading: _isLoading = false,
|
||||
}: ExtendedLegendProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Toggle states for relations
|
||||
const [showUpdates, setShowUpdates] = useState(true)
|
||||
const [showExtends, setShowExtends] = useState(true)
|
||||
const [showInferences, setShowInferences] = useState(false)
|
||||
|
||||
// Toggle states for similarity
|
||||
const [showStrong, setShowStrong] = useState(true)
|
||||
const [showWeak, setShowWeak] = useState(true)
|
||||
|
||||
// Expanded accordion states
|
||||
const [memoriesExpanded, setMemoriesExpanded] = useState(false)
|
||||
const [documentsExpanded, setDocumentsExpanded] = useState(false)
|
||||
const [connectionsExpanded, setConnectionsExpanded] = useState(true)
|
||||
|
||||
// Load saved preference on client side
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
const savedState = getCookie("legendCollapsed")
|
||||
if (savedState === "true") {
|
||||
setIsExpanded(false)
|
||||
} else if (savedState === "false") {
|
||||
setIsExpanded(true)
|
||||
} else {
|
||||
// Default: collapsed on mobile, collapsed on desktop too (per Figma)
|
||||
setIsExpanded(false)
|
||||
}
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}, [isInitialized])
|
||||
|
||||
// Save to cookie when state changes
|
||||
const handleToggleExpanded = (expanded: boolean) => {
|
||||
setIsExpanded(expanded)
|
||||
setCookie("legendCollapsed", expanded ? "false" : "true")
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const memoryCount = nodes.filter((n) => n.type === "memory").length
|
||||
const documentCount = nodes.filter((n) => n.type === "document").length
|
||||
const connectionCount = edges.length
|
||||
|
||||
// Hide on mobile
|
||||
if (isMobile) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("absolute z-20 overflow-hidden", "bottom-4 left-4")}
|
||||
style={{
|
||||
width: "214px",
|
||||
}}
|
||||
id={id}
|
||||
>
|
||||
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
|
||||
{/* Glass background */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-[10px]"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 p-3">
|
||||
{/* Header - always visible */}
|
||||
<CollapsibleTrigger className="flex flex-row items-center gap-1.5 w-full">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-[#FAFAFA]" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span
|
||||
className="text-sm text-white font-normal"
|
||||
style={{
|
||||
fontFamily: "DM Sans",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
Legend
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className="mt-4 flex flex-row gap-3 overflow-y-auto max-h-[312px]"
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
>
|
||||
{/* Main content column */}
|
||||
<div className="flex flex-col gap-4 flex-1">
|
||||
{/* STATISTICS Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span
|
||||
className="text-xs text-[#737373] font-normal"
|
||||
style={{ fontFamily: "Space Grotesk" }}
|
||||
>
|
||||
STATISTICS
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Memories */}
|
||||
<StatRow
|
||||
icon={
|
||||
<HexagonIcon
|
||||
fill="#0D2034"
|
||||
stroke="#3B73B8"
|
||||
size={12}
|
||||
/>
|
||||
}
|
||||
label="Memories"
|
||||
count={memoryCount}
|
||||
expandable
|
||||
expanded={memoriesExpanded}
|
||||
onToggle={() => setMemoriesExpanded(!memoriesExpanded)}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 pl-0">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<HexagonIcon
|
||||
fill="#0D2034"
|
||||
stroke="#3B73B8"
|
||||
size={12}
|
||||
/>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Memory (latest)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">76</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<HexagonIcon
|
||||
fill="#0D2034"
|
||||
stroke="#5F7085"
|
||||
opacity={0.6}
|
||||
size={12}
|
||||
/>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Memory (oldest)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">182</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 flex items-center justify-center">
|
||||
<HexagonIcon
|
||||
fill="#0C1827"
|
||||
stroke="rgba(54, 155, 253, 0.2)"
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Score
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">23</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
points="6,0 11.2,6 6,12 0.8,6"
|
||||
fill="#00FFA9"
|
||||
fillOpacity="0.6"
|
||||
stroke="#00FFA9"
|
||||
strokeWidth="0.6"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
New memory
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">17</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
points="6,0 11.2,6 6,12 0.8,6"
|
||||
fill="#4D2E00"
|
||||
fillOpacity="0.6"
|
||||
stroke="#FE9900"
|
||||
strokeWidth="0.6"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Expiring soon
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">11</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 relative shrink-0">
|
||||
<HexagonIcon
|
||||
fill="#60272C"
|
||||
stroke="#FF6467"
|
||||
opacity={0.6}
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Forgotten
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#737373]">6</span>
|
||||
</div>
|
||||
</div>
|
||||
</StatRow>
|
||||
|
||||
{/* Documents */}
|
||||
<StatRow
|
||||
icon={<DocumentIcon />}
|
||||
label="Documents"
|
||||
count={documentCount}
|
||||
expandable
|
||||
expanded={documentsExpanded}
|
||||
onToggle={() => setDocumentsExpanded(!documentsExpanded)}
|
||||
/>
|
||||
|
||||
{/* Connections */}
|
||||
<StatRow
|
||||
icon={<ConnectionIcon />}
|
||||
label="Connections"
|
||||
count={connectionCount}
|
||||
expandable
|
||||
expanded={connectionsExpanded}
|
||||
onToggle={() =>
|
||||
setConnectionsExpanded(!connectionsExpanded)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<LineIcon color="#5070A1" />
|
||||
<span className="text-xs text-[#FAFAFA]">
|
||||
Doc > Memory
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleRow
|
||||
icon={<LineIcon color="#5070A1" dashed />}
|
||||
label="Doc similarity"
|
||||
checked={showStrong}
|
||||
onChange={setShowStrong}
|
||||
/>
|
||||
</div>
|
||||
</StatRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RELATIONS Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span
|
||||
className="text-xs text-[#737373] font-normal"
|
||||
style={{ fontFamily: "Space Grotesk" }}
|
||||
>
|
||||
RELATIONS
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<ToggleRow
|
||||
icon={<LineIcon color="#7800AB" />}
|
||||
label="Updates"
|
||||
checked={showUpdates}
|
||||
onChange={setShowUpdates}
|
||||
/>
|
||||
<ToggleRow
|
||||
icon={<LineIcon color="#00732E" />}
|
||||
label="Extends"
|
||||
checked={showExtends}
|
||||
onChange={setShowExtends}
|
||||
/>
|
||||
<ToggleRow
|
||||
icon={<LineIcon color="#0054D1" />}
|
||||
label="Inferences"
|
||||
checked={showInferences}
|
||||
onChange={setShowInferences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SIMILARITY Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span
|
||||
className="text-xs text-[#737373] font-normal"
|
||||
style={{ fontFamily: "Space Grotesk" }}
|
||||
>
|
||||
SIMILARITY
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<ToggleRow
|
||||
icon={<SimilarityCircle variant="strong" />}
|
||||
label="Strong"
|
||||
checked={showStrong}
|
||||
onChange={setShowStrong}
|
||||
/>
|
||||
<ToggleRow
|
||||
icon={<SimilarityCircle variant="weak" />}
|
||||
label="Weak"
|
||||
checked={showWeak}
|
||||
onChange={setShowWeak}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollbar indicator */}
|
||||
<div
|
||||
className="w-0.5 h-12 rounded-sm self-start"
|
||||
style={{ background: "#737373" }}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Legend.displayName = "Legend"
|
||||
32
apps/web/components/new/memory-graph/loading-indicator.tsx
Normal file
32
apps/web/components/new/memory-graph/loading-indicator.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { memo } from "react"
|
||||
import type { LoadingIndicatorProps } from "./types"
|
||||
|
||||
export const LoadingIndicator = memo<LoadingIndicatorProps>(
|
||||
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
|
||||
if (!isLoading && !isLoadingMore) return null
|
||||
|
||||
return (
|
||||
<div className="absolute z-30 rounded-xl overflow-hidden top-[5.5rem] left-4">
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
|
||||
<div className="relative z-10 text-slate-300 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 animate-spin text-blue-300" />
|
||||
<span className="text-sm">
|
||||
{isLoading
|
||||
? "Loading memory graph..."
|
||||
: `Loading more documents... (${totalLoaded})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
LoadingIndicator.displayName = "LoadingIndicator"
|
||||
515
apps/web/components/new/memory-graph/memory-graph.tsx
Normal file
515
apps/web/components/new/memory-graph/memory-graph.tsx
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
"use client"
|
||||
|
||||
import { GlassMenuEffect } from "@repo/ui/other/glass-effect"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { GraphCanvas } from "./graph-canvas"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
import { useGraphData } from "./hooks/use-graph-data"
|
||||
import { ForceSimulation } from "./canvas/simulation"
|
||||
import { VersionChainIndex } from "./canvas/version-chain"
|
||||
import type { ViewportState } from "./canvas/viewport"
|
||||
import { Legend } from "./legend"
|
||||
import { LoadingIndicator } from "./loading-indicator"
|
||||
import { NavigationControls } from "./navigation-controls"
|
||||
import { NodeHoverPopover } from "./node-hover-popover"
|
||||
import { colors } from "./constants"
|
||||
import type { GraphNode } from "./types"
|
||||
|
||||
export interface MemoryGraphProps {
|
||||
children?: React.ReactNode
|
||||
isLoading?: boolean
|
||||
error?: Error | null
|
||||
variant?: "console" | "consumer"
|
||||
legendId?: string
|
||||
highlightDocumentIds?: string[]
|
||||
highlightsVisible?: boolean
|
||||
containerTags?: string[]
|
||||
documentIds?: string[]
|
||||
maxNodes?: number
|
||||
isSlideshowActive?: boolean
|
||||
onSlideshowNodeChange?: (nodeId: string | null) => void
|
||||
onSlideshowStop?: () => void
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
}
|
||||
|
||||
export const MemoryGraph = ({
|
||||
children,
|
||||
isLoading: externalIsLoading = false,
|
||||
error: externalError = null,
|
||||
variant = "console",
|
||||
legendId,
|
||||
highlightDocumentIds = [],
|
||||
highlightsVisible = true,
|
||||
containerTags,
|
||||
documentIds,
|
||||
maxNodes = 200,
|
||||
isSlideshowActive = false,
|
||||
onSlideshowNodeChange,
|
||||
onSlideshowStop,
|
||||
canvasRef,
|
||||
}: MemoryGraphProps) => {
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
||||
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<ViewportState | null>(null)
|
||||
const simulationRef = useRef<ForceSimulation | null>(null)
|
||||
const chainIndex = useRef(new VersionChainIndex())
|
||||
|
||||
// React state only for things that affect DOM
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||
const [zoomDisplay, setZoomDisplay] = useState(50)
|
||||
|
||||
const {
|
||||
data: apiData,
|
||||
isLoading: apiIsLoading,
|
||||
error: apiError,
|
||||
} = useGraphApi({
|
||||
containerTags,
|
||||
documentIds,
|
||||
limit: maxNodes,
|
||||
enabled: containerSize.width > 0 && containerSize.height > 0,
|
||||
})
|
||||
|
||||
const { nodes, edges } = useGraphData(
|
||||
apiData.documents,
|
||||
apiData.edges,
|
||||
null,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
|
||||
// Rebuild version chain index when documents change
|
||||
useEffect(() => {
|
||||
chainIndex.current.rebuild(apiData.documents)
|
||||
}, [apiData.documents])
|
||||
|
||||
// Force simulation (created once, updated when data changes)
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) return
|
||||
|
||||
if (!simulationRef.current) {
|
||||
simulationRef.current = new ForceSimulation()
|
||||
}
|
||||
simulationRef.current.init(nodes, edges)
|
||||
|
||||
return () => {
|
||||
simulationRef.current?.destroy()
|
||||
simulationRef.current = null
|
||||
}
|
||||
}, [nodes, edges])
|
||||
|
||||
// Auto-fit when data first loads
|
||||
const hasAutoFittedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasAutoFittedRef.current &&
|
||||
nodes.length > 0 &&
|
||||
viewportRef.current &&
|
||||
containerSize.width > 0
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
viewportRef.current?.fitToNodes(
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
hasAutoFittedRef.current = true
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [nodes, containerSize.width, containerSize.height])
|
||||
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) hasAutoFittedRef.current = false
|
||||
}, [nodes.length])
|
||||
|
||||
// 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())
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Callbacks for GraphCanvas
|
||||
const handleNodeHover = useCallback(
|
||||
(id: string | null) => setHoveredNode(id),
|
||||
[],
|
||||
)
|
||||
|
||||
const handleNodeClick = useCallback((id: string | null) => {
|
||||
setSelectedNode((prev) => (id === null ? null : prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const handleNodeDragStart = useCallback((_id: string) => {
|
||||
// Drag is handled imperatively by InputHandler
|
||||
}, [])
|
||||
|
||||
const handleNodeDragEnd = useCallback(() => {
|
||||
// Drag end handled by InputHandler
|
||||
}, [])
|
||||
|
||||
const handleViewportChange = useCallback((zoom: number) => {
|
||||
setZoomDisplay(Math.round(zoom * 100))
|
||||
}, [])
|
||||
|
||||
// Navigation
|
||||
const handleAutoFit = useCallback(() => {
|
||||
if (nodes.length === 0 || !viewportRef.current) return
|
||||
viewportRef.current.fitToNodes(
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
}, [nodes, containerSize.width, containerSize.height])
|
||||
|
||||
const handleCenter = useCallback(() => {
|
||||
if (nodes.length === 0 || !viewportRef.current) return
|
||||
let sx = 0
|
||||
let sy = 0
|
||||
for (const n of nodes) {
|
||||
sx += n.x
|
||||
sy += n.y
|
||||
}
|
||||
viewportRef.current.centerOn(
|
||||
sx / nodes.length,
|
||||
sy / nodes.length,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
}, [nodes, containerSize.width, containerSize.height])
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, containerSize.height / 2)
|
||||
}, [containerSize.width, containerSize.height])
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2)
|
||||
}, [containerSize.width, containerSize.height])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useHotkeys("z", handleAutoFit, [handleAutoFit])
|
||||
useHotkeys("c", handleCenter, [handleCenter])
|
||||
useHotkeys("equal", handleZoomIn, [handleZoomIn])
|
||||
useHotkeys("minus", handleZoomOut, [handleZoomOut])
|
||||
useHotkeys("escape", () => setSelectedNode(null), [])
|
||||
|
||||
// Arrow key navigation through nodes
|
||||
const selectAndCenter = useCallback(
|
||||
(nodeId: string) => {
|
||||
setSelectedNode(nodeId)
|
||||
const n = nodes.find((nd) => nd.id === nodeId)
|
||||
if (n && viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
n.x,
|
||||
n.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
},
|
||||
[nodes, containerSize.width, containerSize.height],
|
||||
)
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
const chain = chainIndex.current.getChain(selectedNode)
|
||||
if (chain && chain.length > 1) {
|
||||
const idx = chain.findIndex((e) => e.id === selectedNode)
|
||||
if (idx > 0) {
|
||||
selectAndCenter(chain[idx - 1]!.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
// At top of chain or no chain — go to parent document
|
||||
const node = nodes.find((n) => n.id === selectedNode)
|
||||
if (node?.type === "memory" && "documentId" in node.data) {
|
||||
selectAndCenter(node.data.documentId)
|
||||
}
|
||||
}, [selectedNode, nodes, selectAndCenter])
|
||||
|
||||
const navigateDown = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
// Version chain navigation
|
||||
const chain = chainIndex.current.getChain(selectedNode)
|
||||
if (chain && chain.length > 1) {
|
||||
const idx = chain.findIndex((e) => e.id === selectedNode)
|
||||
if (idx >= 0 && idx < chain.length - 1) {
|
||||
selectAndCenter(chain[idx + 1]!.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
// On a document — go to its first memory
|
||||
const node = nodes.find((n) => n.id === selectedNode)
|
||||
if (node?.type === "document") {
|
||||
const child = nodes.find(
|
||||
(n) =>
|
||||
n.type === "memory" &&
|
||||
"documentId" in n.data &&
|
||||
n.data.documentId === selectedNode,
|
||||
)
|
||||
if (child) selectAndCenter(child.id)
|
||||
}
|
||||
}, [selectedNode, nodes, selectAndCenter])
|
||||
|
||||
const navigateNext = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
const node = nodes.find((n) => n.id === selectedNode)
|
||||
if (!node) return
|
||||
|
||||
if (node.type === "document") {
|
||||
const docs = nodes.filter((n) => n.type === "document")
|
||||
const idx = docs.findIndex((n) => n.id === selectedNode)
|
||||
const next = docs[(idx + 1) % docs.length]!
|
||||
setSelectedNode(next.id)
|
||||
if (viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
next.x,
|
||||
next.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
} else {
|
||||
const docId = "documentId" in node.data ? node.data.documentId : null
|
||||
const siblings = nodes.filter(
|
||||
(n) =>
|
||||
n.type === "memory" &&
|
||||
"documentId" in n.data &&
|
||||
n.data.documentId === docId,
|
||||
)
|
||||
if (siblings.length === 0) return
|
||||
const idx = siblings.findIndex((n) => n.id === selectedNode)
|
||||
const next = siblings[(idx + 1) % siblings.length]!
|
||||
setSelectedNode(next.id)
|
||||
if (viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
next.x,
|
||||
next.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
}
|
||||
}, [selectedNode, nodes, containerSize.width, containerSize.height])
|
||||
|
||||
const navigatePrev = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
const node = nodes.find((n) => n.id === selectedNode)
|
||||
if (!node) return
|
||||
|
||||
if (node.type === "document") {
|
||||
const docs = nodes.filter((n) => n.type === "document")
|
||||
const idx = docs.findIndex((n) => n.id === selectedNode)
|
||||
const prev = docs[(idx - 1 + docs.length) % docs.length]!
|
||||
setSelectedNode(prev.id)
|
||||
if (viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
prev.x,
|
||||
prev.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
} else {
|
||||
const docId = "documentId" in node.data ? node.data.documentId : null
|
||||
const siblings = nodes.filter(
|
||||
(n) =>
|
||||
n.type === "memory" &&
|
||||
"documentId" in n.data &&
|
||||
n.data.documentId === docId,
|
||||
)
|
||||
if (siblings.length === 0) return
|
||||
const idx = siblings.findIndex((n) => n.id === selectedNode)
|
||||
const prev = siblings[(idx - 1 + siblings.length) % siblings.length]!
|
||||
setSelectedNode(prev.id)
|
||||
if (viewportRef.current)
|
||||
viewportRef.current.centerOn(
|
||||
prev.x,
|
||||
prev.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
}
|
||||
}, [selectedNode, nodes, containerSize.width, containerSize.height])
|
||||
|
||||
useHotkeys("up", navigateUp, [navigateUp])
|
||||
useHotkeys("down", navigateDown, [navigateDown])
|
||||
useHotkeys("right", navigateNext, [navigateNext])
|
||||
useHotkeys("left", navigatePrev, [navigatePrev])
|
||||
|
||||
// Slideshow
|
||||
useEffect(() => {
|
||||
if (!isSlideshowActive || nodes.length === 0) {
|
||||
if (!isSlideshowActive) {
|
||||
setSelectedNode(null)
|
||||
simulationRef.current?.coolDown()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let lastIdx = -1
|
||||
const pick = () => {
|
||||
if (nodes.length === 0) return
|
||||
let idx: number
|
||||
if (nodes.length > 1) {
|
||||
do {
|
||||
idx = Math.floor(Math.random() * nodes.length)
|
||||
} while (idx === lastIdx)
|
||||
} else {
|
||||
idx = 0
|
||||
}
|
||||
lastIdx = idx
|
||||
const n = nodes[idx]!
|
||||
setSelectedNode(n.id)
|
||||
viewportRef.current?.centerOn(
|
||||
n.x,
|
||||
n.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
simulationRef.current?.reheat()
|
||||
onSlideshowNodeChange?.(n.id)
|
||||
setTimeout(() => simulationRef.current?.coolDown(), 1000)
|
||||
}
|
||||
|
||||
pick()
|
||||
const interval = setInterval(pick, 3500)
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
isSlideshowActive,
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
onSlideshowNodeChange,
|
||||
])
|
||||
|
||||
// Active node: selected takes priority, then hovered
|
||||
const activeNodeId = selectedNode ?? hoveredNode
|
||||
const activeNodeData = useMemo(() => {
|
||||
if (!activeNodeId) return null
|
||||
return nodes.find((n) => n.id === activeNodeId) ?? null
|
||||
}, [activeNodeId, nodes])
|
||||
|
||||
const activePopoverPosition = useMemo(() => {
|
||||
if (!activeNodeData || !viewportRef.current) return null
|
||||
const vp = viewportRef.current
|
||||
const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y)
|
||||
return {
|
||||
screenX: screen.x,
|
||||
screenY: screen.y,
|
||||
nodeRadius: (activeNodeData.size * vp.zoom) / 2,
|
||||
}
|
||||
}, [activeNodeData])
|
||||
|
||||
const activeVersionChain = useMemo(() => {
|
||||
if (!activeNodeData || activeNodeData.type !== "memory") return null
|
||||
return chainIndex.current.getChain(activeNodeData.id)
|
||||
}, [activeNodeData])
|
||||
|
||||
const isLoading = externalIsLoading || apiIsLoading
|
||||
const error = externalError || apiError
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center"
|
||||
style={{ backgroundColor: colors.background.primary }}
|
||||
>
|
||||
<div className="rounded-xl overflow-hidden">
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
<div className="relative z-10 text-slate-300 px-6 py-4">
|
||||
Error loading graph: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full rounded-xl overflow-hidden">
|
||||
<LoadingIndicator
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={false}
|
||||
totalLoaded={apiData.totalCount}
|
||||
variant={variant}
|
||||
/>
|
||||
|
||||
{!isLoading &&
|
||||
nodes.filter((n) => n.type === "document").length === 0 &&
|
||||
children}
|
||||
|
||||
<div
|
||||
className="w-full h-full relative overflow-hidden touch-none select-none"
|
||||
ref={containerRef}
|
||||
>
|
||||
{containerSize.width > 0 && containerSize.height > 0 && (
|
||||
<GraphCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
width={containerSize.width}
|
||||
height={containerSize.height}
|
||||
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
|
||||
selectedNodeId={selectedNode}
|
||||
onNodeHover={handleNodeHover}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDragStart={handleNodeDragStart}
|
||||
onNodeDragEnd={handleNodeDragEnd}
|
||||
onViewportChange={handleViewportChange}
|
||||
canvasRef={canvasRef}
|
||||
variant={variant}
|
||||
simulation={simulationRef.current ?? undefined}
|
||||
viewportRef={viewportRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeNodeData && activePopoverPosition && (
|
||||
<NodeHoverPopover
|
||||
node={activeNodeData}
|
||||
screenX={activePopoverPosition.screenX}
|
||||
screenY={activePopoverPosition.screenY}
|
||||
nodeRadius={activePopoverPosition.nodeRadius}
|
||||
containerBounds={containerBounds ?? undefined}
|
||||
versionChain={activeVersionChain}
|
||||
onNavigateNext={navigateNext}
|
||||
onNavigatePrev={navigatePrev}
|
||||
onNavigateUp={navigateUp}
|
||||
onNavigateDown={navigateDown}
|
||||
onSelectNode={handleNodeClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{containerSize.width > 0 && (
|
||||
<NavigationControls
|
||||
onCenter={handleCenter}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onAutoFit={handleAutoFit}
|
||||
nodes={nodes}
|
||||
className="absolute bottom-18 left-4 z-15"
|
||||
zoomLevel={zoomDisplay}
|
||||
/>
|
||||
)}
|
||||
<Legend
|
||||
edges={edges}
|
||||
id={legendId}
|
||||
isLoading={isLoading}
|
||||
nodes={nodes}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
apps/web/components/new/memory-graph/navigation-controls.tsx
Normal file
164
apps/web/components/new/memory-graph/navigation-controls.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import type { GraphNode } from "./types"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Settings } from "lucide-react"
|
||||
|
||||
interface NavigationControlsProps {
|
||||
onCenter: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onAutoFit: () => void
|
||||
nodes: GraphNode[]
|
||||
className?: string
|
||||
zoomLevel: number
|
||||
}
|
||||
|
||||
// Keyboard shortcut badge component
|
||||
const KeyboardShortcut = memo(function KeyboardShortcut({
|
||||
keys,
|
||||
}: {
|
||||
keys: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-center gap-1 px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: "rgba(33, 33, 33, 0.5)",
|
||||
border: "1px solid rgba(115, 115, 115, 0.2)",
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-[#737373] font-medium leading-none">
|
||||
{keys}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Navigation buttons component
|
||||
const NavigationButtons = memo(function NavigationButtons({
|
||||
onAutoFit,
|
||||
onCenter,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
zoomLevel,
|
||||
}: {
|
||||
onAutoFit: () => void
|
||||
onCenter: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
zoomLevel: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Fit button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={onAutoFit}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-white font-medium">Fit</span>
|
||||
<KeyboardShortcut keys="Z" />
|
||||
</button>
|
||||
|
||||
{/* Center button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={onCenter}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-white font-medium">Center</span>
|
||||
<KeyboardShortcut keys="C" />
|
||||
</button>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div
|
||||
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-white font-medium">{zoomLevel}%</span>
|
||||
<div className="flex flex-row items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomOut}
|
||||
className="w-5 h-5 flex items-center justify-center rounded bg-black/20 border border-white/10 text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-xs">−</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomIn}
|
||||
className="w-5 h-5 flex items-center justify-center rounded bg-black/20 border border-white/10 text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-xs">+</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SettingsButton = memo(function SettingsButton() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-white/10 transition-colors"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
border: "1px solid rgba(23, 24, 26, 0.7)",
|
||||
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
|
||||
}}
|
||||
>
|
||||
<Settings className="w-5 h-5 text-[#737373]" />
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export const NavigationControls = memo<NavigationControlsProps>(
|
||||
({
|
||||
onCenter,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onAutoFit,
|
||||
nodes,
|
||||
className = "",
|
||||
zoomLevel,
|
||||
}) => {
|
||||
if (nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
<div className="flex flex-row items-end gap-2">
|
||||
<NavigationButtons
|
||||
onAutoFit={onAutoFit}
|
||||
onCenter={onCenter}
|
||||
onZoomIn={onZoomIn}
|
||||
onZoomOut={onZoomOut}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
{/* Commented out for now as we are not using this */}
|
||||
{/*<SettingsButton />*/}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
NavigationControls.displayName = "NavigationControls"
|
||||
467
apps/web/components/new/memory-graph/node-hover-popover.tsx
Normal file
467
apps/web/components/new/memory-graph/node-hover-popover.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useMemo, useCallback, useState } from "react"
|
||||
import type { GraphNode, DocumentNodeData, MemoryNodeData } from "./types"
|
||||
import type { ChainEntry } from "./canvas/version-chain"
|
||||
|
||||
export interface NodeHoverPopoverProps {
|
||||
node: GraphNode
|
||||
screenX: number
|
||||
screenY: number
|
||||
nodeRadius: number
|
||||
containerBounds?: DOMRect
|
||||
versionChain?: ChainEntry[] | null
|
||||
onNavigateNext?: () => void
|
||||
onNavigatePrev?: () => void
|
||||
onNavigateUp?: () => void
|
||||
onNavigateDown?: () => void
|
||||
onSelectNode?: (nodeId: string) => void
|
||||
}
|
||||
|
||||
function KeyBadge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "#181A1E",
|
||||
border: "1px solid #2A2C2F",
|
||||
color: "#737373",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ background: "none", border: "none", padding: "2px 0" }}
|
||||
>
|
||||
<KeyBadge>{icon}</KeyBadge>
|
||||
<span
|
||||
className="text-[11px] whitespace-nowrap"
|
||||
style={{ color: "#525D6E" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyableId({ label, value }: { label: string; value: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = useCallback(() => {
|
||||
navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}, [value])
|
||||
|
||||
const short =
|
||||
value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="flex items-center gap-1.5 group cursor-pointer"
|
||||
style={{ background: "none", border: "none", padding: 0 }}
|
||||
>
|
||||
<span className="text-[10px]" style={{ color: "#525D6E" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-mono group-hover:text-white transition-colors"
|
||||
style={{ color: "#737373" }}
|
||||
>
|
||||
{copied ? "Copied!" : short}
|
||||
</span>
|
||||
{!copied && (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#525D6E"
|
||||
strokeWidth="2"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type Quadrant = "right" | "left" | "above" | "below"
|
||||
|
||||
function pickBestQuadrant(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
nodeRadius: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
popoverWidth: number,
|
||||
popoverHeight: number,
|
||||
): Quadrant {
|
||||
const gap = 24
|
||||
const spaceRight = containerWidth - (screenX + nodeRadius + gap)
|
||||
const spaceLeft = screenX - nodeRadius - gap
|
||||
const spaceAbove = screenY - nodeRadius - gap
|
||||
const spaceBelow = containerHeight - (screenY + nodeRadius + gap)
|
||||
|
||||
const fits: [Quadrant, number][] = [
|
||||
["right", spaceRight >= popoverWidth ? spaceRight : -1],
|
||||
["left", spaceLeft >= popoverWidth ? spaceLeft : -1],
|
||||
["above", spaceAbove >= popoverHeight ? spaceAbove : -1],
|
||||
["below", spaceBelow >= popoverHeight ? spaceBelow : -1],
|
||||
]
|
||||
|
||||
const preferred: Quadrant[] = ["right", "left", "below", "above"]
|
||||
for (const q of preferred) {
|
||||
const entry = fits.find(([dir]) => dir === q)
|
||||
if (entry && entry[1] > 0) return q
|
||||
}
|
||||
|
||||
return fits.sort((a, b) => b[1] - a[1])[0]![0]
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number) {
|
||||
return s.length > max ? `${s.substring(0, max)}...` : s
|
||||
}
|
||||
|
||||
function VersionTimeline({
|
||||
chain,
|
||||
currentId,
|
||||
onSelect,
|
||||
}: {
|
||||
chain: ChainEntry[]
|
||||
currentId: string
|
||||
onSelect?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0 max-h-[120px] overflow-y-auto">
|
||||
{chain.map((entry) => {
|
||||
const isCurrent = entry.id === currentId
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(entry.id)}
|
||||
className="flex items-start gap-2 px-3 py-1.5 text-left cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: isCurrent ? "#0A1825" : "transparent",
|
||||
border: "none",
|
||||
borderLeft: isCurrent ? "2px solid #36FDFD" : "2px solid #1A2333",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-semibold shrink-0 mt-px"
|
||||
style={{
|
||||
color: entry.isForgotten
|
||||
? "#DC2626"
|
||||
: isCurrent
|
||||
? "#36FDFD"
|
||||
: "#525D6E",
|
||||
}}
|
||||
>
|
||||
v{entry.version}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] leading-tight"
|
||||
style={{
|
||||
color: isCurrent ? "#9CA3AF" : "#4A5568",
|
||||
}}
|
||||
>
|
||||
{truncate(entry.memory, 60)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
||||
function NodeHoverPopover({
|
||||
node,
|
||||
screenX,
|
||||
screenY,
|
||||
nodeRadius,
|
||||
containerBounds,
|
||||
versionChain,
|
||||
onNavigateNext,
|
||||
onNavigatePrev,
|
||||
onNavigateUp,
|
||||
onNavigateDown,
|
||||
onSelectNode,
|
||||
}) {
|
||||
const CARD_W = 280
|
||||
const SHORTCUTS_W = 100
|
||||
const GAP = 24
|
||||
const TOTAL_W = CARD_W + 12 + SHORTCUTS_W
|
||||
|
||||
const isMemory = node.type === "memory"
|
||||
const data = node.data
|
||||
|
||||
const memoryMeta = useMemo(() => {
|
||||
if (!isMemory) return null
|
||||
const md = data as MemoryNodeData
|
||||
return {
|
||||
version: md.version ?? 1,
|
||||
isLatest: md.isLatest ?? false,
|
||||
isForgotten: md.isForgotten ?? false,
|
||||
forgetReason: md.forgetReason ?? null,
|
||||
forgetAfter: md.forgetAfter ?? null,
|
||||
}
|
||||
}, [isMemory, data])
|
||||
|
||||
const hasChain = versionChain && versionChain.length > 1
|
||||
const hasForgetInfo =
|
||||
memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter)
|
||||
|
||||
const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135
|
||||
const TOTAL_H = CARD_H
|
||||
|
||||
const { popoverX, popoverY, connectorPath } = useMemo(() => {
|
||||
const cw = containerBounds?.width ?? 800
|
||||
const ch = containerBounds?.height ?? 600
|
||||
|
||||
const quadrant = pickBestQuadrant(
|
||||
screenX,
|
||||
screenY,
|
||||
nodeRadius,
|
||||
cw,
|
||||
ch,
|
||||
TOTAL_W + GAP,
|
||||
TOTAL_H,
|
||||
)
|
||||
|
||||
let px: number
|
||||
let py: number
|
||||
let connStart: { x: number; y: number }
|
||||
|
||||
switch (quadrant) {
|
||||
case "right":
|
||||
px = screenX + nodeRadius + GAP
|
||||
py = screenY - TOTAL_H / 2
|
||||
connStart = { x: screenX + nodeRadius, y: screenY }
|
||||
break
|
||||
case "left":
|
||||
px = screenX - nodeRadius - GAP - TOTAL_W
|
||||
py = screenY - TOTAL_H / 2
|
||||
connStart = { x: screenX - nodeRadius, y: screenY }
|
||||
break
|
||||
case "below":
|
||||
px = screenX - TOTAL_W / 2
|
||||
py = screenY + nodeRadius + GAP
|
||||
connStart = { x: screenX, y: screenY + nodeRadius }
|
||||
break
|
||||
case "above":
|
||||
px = screenX - TOTAL_W / 2
|
||||
py = screenY - nodeRadius - GAP - TOTAL_H
|
||||
connStart = { x: screenX, y: screenY - nodeRadius }
|
||||
break
|
||||
}
|
||||
|
||||
px = Math.max(8, Math.min(cw - TOTAL_W - 8, px))
|
||||
py = Math.max(8, Math.min(ch - TOTAL_H - 8, py))
|
||||
|
||||
const cardCenterX = px + CARD_W / 2
|
||||
const cardCenterY = py + TOTAL_H / 2
|
||||
const path = `M ${connStart.x} ${connStart.y} L ${cardCenterX} ${cardCenterY}`
|
||||
|
||||
return { popoverX: px, popoverY: py, connectorPath: path }
|
||||
}, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H])
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (isMemory) {
|
||||
const md = data as MemoryNodeData
|
||||
return md.memory || md.content || ""
|
||||
}
|
||||
const dd = data as DocumentNodeData
|
||||
return dd.summary || dd.title || ""
|
||||
}, [isMemory, data])
|
||||
|
||||
const docData = !isMemory ? (data as DocumentNodeData) : null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-[100]">
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full overflow-visible"
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
<path
|
||||
d={connectorPath}
|
||||
stroke="#3B73B8"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute flex gap-3 pointer-events-auto"
|
||||
style={{ left: popoverX, top: popoverY }}
|
||||
>
|
||||
{/* Card */}
|
||||
<div
|
||||
className="flex flex-col rounded-xl overflow-hidden"
|
||||
style={{ width: CARD_W, backgroundColor: "#0C1829" }}
|
||||
>
|
||||
{/* Content — show timeline if chain exists, otherwise plain text */}
|
||||
{hasChain ? (
|
||||
<VersionTimeline
|
||||
chain={versionChain}
|
||||
currentId={node.id}
|
||||
onSelect={onSelectNode}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="p-3 overflow-hidden"
|
||||
style={{ backgroundColor: "#060D17" }}
|
||||
>
|
||||
<p
|
||||
className="m-0 leading-[135%]"
|
||||
style={{
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
fontSize: 12,
|
||||
color: "#525D6E",
|
||||
}}
|
||||
>
|
||||
{truncate(content, 100) || "No content"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forget info (memory-only) */}
|
||||
{memoryMeta && hasForgetInfo && (
|
||||
<div
|
||||
className="px-3 py-1.5 flex flex-col gap-0.5"
|
||||
style={{
|
||||
backgroundColor: "#0A1320",
|
||||
borderTop: "1px solid #1A2333",
|
||||
}}
|
||||
>
|
||||
{memoryMeta.forgetAfter && (
|
||||
<span className="text-[10px]" style={{ color: "#F59E0B" }}>
|
||||
Expires:{" "}
|
||||
{new Date(memoryMeta.forgetAfter).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{memoryMeta.forgetReason && (
|
||||
<span className="text-[10px]" style={{ color: "#8B8B8B" }}>
|
||||
Reason: {memoryMeta.forgetReason}
|
||||
</span>
|
||||
)}
|
||||
{memoryMeta.isForgotten && !memoryMeta.forgetReason && (
|
||||
<span className="text-[10px]" style={{ color: "#EF4444" }}>
|
||||
Forgotten
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: "#0C1829",
|
||||
borderTop: "1px solid #1A2333",
|
||||
}}
|
||||
>
|
||||
{memoryMeta ? (
|
||||
<>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{
|
||||
color: memoryMeta.isForgotten
|
||||
? "#DC2626"
|
||||
: memoryMeta.isLatest
|
||||
? "#05A376"
|
||||
: "#525D6E",
|
||||
}}
|
||||
>
|
||||
v{memoryMeta.version}{" "}
|
||||
{memoryMeta.isForgotten
|
||||
? "Forgotten"
|
||||
: memoryMeta.isLatest
|
||||
? "Latest"
|
||||
: "Superseded"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs" style={{ color: "#525D6E" }}>
|
||||
{docData?.type || "document"}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: "#525D6E" }}>
|
||||
{docData?.memories?.length ?? 0} memories
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ID row */}
|
||||
<div
|
||||
className="px-3 py-1.5 flex items-center"
|
||||
style={{
|
||||
backgroundColor: "#080E18",
|
||||
borderTop: "1px solid #1A2333",
|
||||
}}
|
||||
>
|
||||
{isMemory ? (
|
||||
<CopyableId label="Memory" value={node.id} />
|
||||
) : (
|
||||
<CopyableId label="Document" value={node.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div
|
||||
className="flex flex-col justify-center gap-1.5 px-3 py-2 rounded-lg"
|
||||
style={{ backgroundColor: "#0C1829" }}
|
||||
>
|
||||
{isMemory && (
|
||||
<NavButton
|
||||
icon="↑"
|
||||
label={hasChain ? "Older version" : "Go to document"}
|
||||
onClick={onNavigateUp}
|
||||
/>
|
||||
)}
|
||||
{(isMemory ? hasChain : true) && (
|
||||
<NavButton
|
||||
icon="↓"
|
||||
label={isMemory ? "Newer version" : "Go to memory"}
|
||||
onClick={onNavigateDown}
|
||||
/>
|
||||
)}
|
||||
<NavButton
|
||||
icon="→"
|
||||
label={isMemory ? "Next memory" : "Next document"}
|
||||
onClick={onNavigateNext}
|
||||
/>
|
||||
<NavButton
|
||||
icon="←"
|
||||
label={isMemory ? "Prev memory" : "Prev document"}
|
||||
onClick={onNavigatePrev}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
362
apps/web/components/new/memory-graph/node-popover.tsx
Normal file
362
apps/web/components/new/memory-graph/node-popover.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useEffect } from "react"
|
||||
import type { GraphNode } from "./types"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
export interface NodePopoverProps {
|
||||
node: GraphNode
|
||||
x: number // Screen X position
|
||||
y: number // Screen Y position
|
||||
onClose: () => void
|
||||
containerBounds?: DOMRect // Optional container bounds to limit backdrop
|
||||
onBackdropClick?: () => void // Optional callback when backdrop is clicked
|
||||
}
|
||||
|
||||
export const NodePopover = memo<NodePopoverProps>(function NodePopover({
|
||||
node,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
containerBounds,
|
||||
onBackdropClick,
|
||||
}) {
|
||||
// Handle Escape key to close popover
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
// Calculate backdrop bounds - use container bounds if provided, otherwise full viewport
|
||||
const backdropStyle = containerBounds
|
||||
? {
|
||||
left: `${containerBounds.left}px`,
|
||||
top: `${containerBounds.top}px`,
|
||||
width: `${containerBounds.width}px`,
|
||||
height: `${containerBounds.height}px`,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
onBackdropClick?.()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Invisible backdrop to catch clicks outside */}
|
||||
<div
|
||||
onClick={handleBackdropClick}
|
||||
className={cn(
|
||||
"fixed z-[999] pointer-events-auto bg-transparent",
|
||||
!containerBounds && "inset-0",
|
||||
)}
|
||||
style={backdropStyle}
|
||||
/>
|
||||
|
||||
{/* Popover content */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside
|
||||
className="fixed backdrop-blur-[12px] bg-white/5 border border-white/25 rounded-xl p-4 w-80 z-[1000] pointer-events-auto shadow-[0_20px_25px_-5px_rgb(0_0_0/0.3),0_8px_10px_-6px_rgb(0_0_0/0.3)]"
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
}}
|
||||
>
|
||||
{node.type === "document" ? (
|
||||
// Document popover
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-slate-400"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<h3 className="text-base font-bold text-white m-0">Document</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 bg-transparent border-none text-slate-400 cursor-pointer text-base leading-none transition-colors hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Title
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).title || "Untitled Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary - truncated to 2 lines */}
|
||||
{(node.data as any).summary && (
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Summary
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed line-clamp-2">
|
||||
{(node.data as any).summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Type
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).type || "Document"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Memory Count */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Memory Count
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).memoryEntries?.length || 0} memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
{((node.data as any).url || (node.data as any).customId) && (
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
URL
|
||||
</div>
|
||||
<a
|
||||
href={(() => {
|
||||
const doc = node.data as any
|
||||
if (doc.type === "google_doc" && doc.customId) {
|
||||
return `https://docs.google.com/document/d/${doc.customId}`
|
||||
}
|
||||
if (doc.type === "google_sheet" && doc.customId) {
|
||||
return `https://docs.google.com/spreadsheets/d/${doc.customId}`
|
||||
}
|
||||
if (doc.type === "google_slide" && doc.customId) {
|
||||
return `https://docs.google.com/presentation/d/${doc.customId}`
|
||||
}
|
||||
return doc.url ?? undefined
|
||||
})()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-indigo-400 no-underline flex items-center gap-1 transition-colors hover:text-indigo-300"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
View Document
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with metadata */}
|
||||
<div className="pt-3 border-t border-slate-600/50 flex items-center gap-4 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<span>
|
||||
{new Date(
|
||||
(node.data as any).createdAt,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="4" y1="9" x2="20" y2="9" />
|
||||
<line x1="4" y1="15" x2="20" y2="15" />
|
||||
<line x1="10" y1="3" x2="8" y2="21" />
|
||||
<line x1="16" y1="3" x2="14" y2="21" />
|
||||
</svg>
|
||||
<span className="overflow-hidden text-ellipsis">
|
||||
{node.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Memory popover
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-400"
|
||||
>
|
||||
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
|
||||
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
|
||||
</svg>
|
||||
<h3 className="text-base font-bold text-white m-0">Memory</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 bg-transparent border-none text-slate-400 cursor-pointer text-base leading-none transition-colors hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Memory content */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Memory
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).memory ||
|
||||
(node.data as any).content ||
|
||||
"No content"}
|
||||
</p>
|
||||
{(node.data as any).isForgotten && (
|
||||
<div className="mt-2 px-2 py-1 bg-red-600/15 rounded text-xs text-red-400 inline-block">
|
||||
Forgotten
|
||||
</div>
|
||||
)}
|
||||
{/* Expires (inline with memory if exists) */}
|
||||
{(node.data as any).forgetAfter && (
|
||||
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
|
||||
Expires:{" "}
|
||||
{new Date(
|
||||
(node.data as any).forgetAfter,
|
||||
).toLocaleDateString()}
|
||||
{(node.data as any).forgetReason &&
|
||||
` - ${(node.data as any).forgetReason}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Space */}
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
|
||||
Space
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 m-0 leading-relaxed">
|
||||
{(node.data as any).spaceId || "Default"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer with metadata */}
|
||||
<div className="pt-3 border-t border-slate-600/50 flex items-center gap-4 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<span>
|
||||
{new Date(
|
||||
(node.data as any).createdAt,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="4" y1="9" x2="20" y2="9" />
|
||||
<line x1="4" y1="15" x2="20" y2="15" />
|
||||
<line x1="10" y1="3" x2="8" y2="21" />
|
||||
<line x1="16" y1="3" x2="14" y2="21" />
|
||||
</svg>
|
||||
<span className="overflow-hidden text-ellipsis">
|
||||
{node.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
163
apps/web/components/new/memory-graph/types.ts
Normal file
163
apps/web/components/new/memory-graph/types.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import type {
|
||||
DocumentsResponse,
|
||||
DocumentWithMemories,
|
||||
MemoryEntry,
|
||||
} from "./api-types"
|
||||
|
||||
export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }
|
||||
|
||||
// Graph API types matching backend response
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export interface GraphApiDocument {
|
||||
id: string
|
||||
title: string | null
|
||||
summary: string | null
|
||||
documentType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
x: number // backend coordinates (dynamic range)
|
||||
y: number // backend coordinates (dynamic range)
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
export interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
similarity: number // 0-1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Typed node data
|
||||
|
||||
export interface DocumentNodeData {
|
||||
id: string
|
||||
title: string | null
|
||||
summary: string | null
|
||||
type: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
export interface MemoryNodeData {
|
||||
id: string
|
||||
memory: string
|
||||
content: string
|
||||
documentId: string
|
||||
isStatic: boolean
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
forgetAfter: string | null
|
||||
forgetReason: string | null
|
||||
version: number
|
||||
parentMemoryId: string | null
|
||||
spaceId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
type: "document" | "memory"
|
||||
x: number
|
||||
y: number
|
||||
data: DocumentNodeData | MemoryNodeData
|
||||
size: number
|
||||
borderColor: string
|
||||
isHovered: boolean
|
||||
isDragging: boolean
|
||||
// D3-force simulation properties
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
id: string
|
||||
source: string | GraphNode
|
||||
target: string | GraphNode
|
||||
similarity: number
|
||||
visualProps: {
|
||||
opacity: number
|
||||
thickness: number
|
||||
}
|
||||
edgeType: "doc-memory" | "similarity" | "version"
|
||||
}
|
||||
|
||||
export interface GraphCanvasProps {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
width: number
|
||||
height: number
|
||||
highlightDocumentIds?: string[]
|
||||
selectedNodeId?: string | null
|
||||
onNodeHover: (nodeId: string | null) => void
|
||||
onNodeClick: (nodeId: string | null) => void
|
||||
onNodeDragStart: (nodeId: string) => void
|
||||
onNodeDragEnd: () => void
|
||||
onViewportChange?: (zoom: number) => void
|
||||
canvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
variant?: "console" | "consumer"
|
||||
simulation?: import("./canvas/simulation").ForceSimulation
|
||||
viewportRef?: React.RefObject<
|
||||
import("./canvas/viewport").ViewportState | null
|
||||
>
|
||||
}
|
||||
|
||||
export interface LegendProps {
|
||||
variant?: "console" | "consumer"
|
||||
nodes?: GraphNode[]
|
||||
edges?: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
hoveredNode?: string | null
|
||||
}
|
||||
|
||||
export interface LoadingIndicatorProps {
|
||||
isLoading: boolean
|
||||
isLoadingMore: boolean
|
||||
totalLoaded: number
|
||||
variant?: "console" | "consumer"
|
||||
}
|
||||
539
apps/web/components/new/share-modal.tsx
Normal file
539
apps/web/components/new/share-modal.tsx
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@ui/components/dialog"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon, Download, Copy, Check } from "lucide-react"
|
||||
import { GradientLogo } from "@ui/assets/Logo"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { toast } from "sonner"
|
||||
import * as htmlToImage from "html-to-image"
|
||||
|
||||
type BackgroundTheme = "gradient" | "dark-gradient" | "black"
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
graphCanvasRef?: React.RefObject<HTMLCanvasElement | null>
|
||||
}
|
||||
|
||||
// X/Twitter icon
|
||||
const XIcon2 = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.6 0.75H15.054L9.694 6.89L16 15.25H11.063L7.196 10.176L2.771 15.25H0.316L6.05 8.682L0 0.75H5.063L8.558 5.391L12.6 0.75ZM11.74 13.77H13.1L4.324 2.145H2.865L11.74 13.77Z"
|
||||
fill="#737373"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// LinkedIn icon
|
||||
const LinkedInIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3.58065 4.89474H0V16H3.58065V4.89474ZM1.79032 0C0.801613 0 0 0.801613 0 1.79032C0 2.77903 0.801613 3.58065 1.79032 3.58065C2.77903 3.58065 3.58065 2.77903 3.58065 1.79032C3.58065 0.801613 2.77903 0 1.79032 0ZM8.71613 4.89474H5.33871V16H8.71613V10.0645C8.71613 8.46774 9.11774 6.93548 11.1613 6.93548C13.1774 6.93548 13.2097 8.75806 13.2097 10.1613V16H16V9.48387C16 6.74194 15.3871 4.64516 12.1935 4.64516C10.6452 4.64516 9.59677 5.48387 9.16129 6.27419H9.12903V4.89474H8.71613Z"
|
||||
fill="#737373"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Instagram icon
|
||||
const InstagramIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 13 13"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="1.08333"
|
||||
y="1.08333"
|
||||
width="10.8333"
|
||||
height="10.8333"
|
||||
rx="2.58333"
|
||||
stroke="#737373"
|
||||
strokeWidth="1.08333"
|
||||
/>
|
||||
<circle
|
||||
cx="6.5"
|
||||
cy="6.5"
|
||||
r="2.16667"
|
||||
stroke="#737373"
|
||||
strokeWidth="1.08333"
|
||||
/>
|
||||
<circle cx="9.75" cy="3.25" r="0.8125" fill="#737373" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Background gradient overlay component
|
||||
const BackgroundGradient = ({
|
||||
theme,
|
||||
className,
|
||||
}: {
|
||||
theme: BackgroundTheme
|
||||
className?: string
|
||||
}) => {
|
||||
if (theme === "black") {
|
||||
return (
|
||||
<div className={cn("absolute inset-0 bg-black", className)}>
|
||||
{/* Dotted pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, rgba(255,255,255,0.15) 1px, transparent 1px)",
|
||||
backgroundSize: "12px 12px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (theme === "dark-gradient") {
|
||||
return (
|
||||
<div className={cn("absolute inset-0", className)}>
|
||||
{/* Base dark background */}
|
||||
<div className="absolute inset-0 bg-[#030710]" />
|
||||
{/* Blue glow effect */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 100%, rgba(30, 90, 200, 0.3) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 40% 30% at 30% 80%, rgba(60, 120, 255, 0.15) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
{/* Dotted pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, rgba(255,255,255,0.15) 1px, transparent 1px)",
|
||||
backgroundSize: "12px 12px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: gradient theme (first screenshot)
|
||||
return (
|
||||
<div className={cn("absolute inset-0", className)}>
|
||||
{/* Base dark blue gradient */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #030B1A 0%, #0A1A35 50%, #1A3A6A 100%)",
|
||||
}}
|
||||
/>
|
||||
{/* Blue glow from bottom */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 80% 50% at 50% 100%, rgba(30, 100, 220, 0.4) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 50% 30% at 20% 90%, rgba(60, 140, 255, 0.2) 0%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
{/* Dotted pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, rgba(255,255,255,0.15) 1px, transparent 1px)",
|
||||
backgroundSize: "12px 12px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Theme selector button
|
||||
const ThemeButton = ({
|
||||
theme,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
theme: BackgroundTheme
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
}) => {
|
||||
const getPreviewContent = () => {
|
||||
if (theme === "black") {
|
||||
return <div className="w-full h-full bg-black rounded" />
|
||||
}
|
||||
if (theme === "dark-gradient") {
|
||||
return (
|
||||
<div className="w-full h-full rounded relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[#030710]" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 100%, rgba(30, 90, 200, 0.4) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full rounded relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #030B1A 0%, #0A1A35 50%, #1A3A6A 100%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 80% 50% at 50% 100%, rgba(30, 100, 220, 0.5) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-10 h-8 rounded overflow-hidden transition-all",
|
||||
isSelected
|
||||
? "ring-2 ring-[#4BA0FA] ring-offset-1 ring-offset-transparent"
|
||||
: "border border-[rgba(115,115,115,0.2)]",
|
||||
)}
|
||||
style={{
|
||||
background: "#0D121A",
|
||||
boxShadow: "inset 1.3125px 1.3125px 3.9375px rgba(0, 0, 0, 0.7)",
|
||||
}}
|
||||
>
|
||||
{getPreviewContent()}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Social button component
|
||||
const SocialButton = ({
|
||||
icon,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
onClick: () => void
|
||||
label: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
||||
style={{
|
||||
background: "#0D121A",
|
||||
border: "0.875px solid rgba(115, 115, 115, 0.2)",
|
||||
boxShadow: "inset 1.3125px 1.3125px 3.9375px rgba(0, 0, 0, 0.7)",
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
|
||||
export function ShareModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
graphCanvasRef,
|
||||
}: ShareModalProps) {
|
||||
const { user } = useAuth()
|
||||
const [selectedTheme, setSelectedTheme] =
|
||||
useState<BackgroundTheme>("gradient")
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const displayName =
|
||||
user?.displayUsername ||
|
||||
(typeof window !== "undefined" ? localStorage.getItem("username") : null) ||
|
||||
(typeof window !== "undefined" ? localStorage.getItem("userName") : null) ||
|
||||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "Your"
|
||||
|
||||
const capturePreview = useCallback(async (): Promise<Blob | null> => {
|
||||
if (!previewRef.current) return null
|
||||
|
||||
try {
|
||||
const dataUrl = await htmlToImage.toPng(previewRef.current, {
|
||||
pixelRatio: 2,
|
||||
quality: 1,
|
||||
})
|
||||
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(dataUrl)
|
||||
const blob = await response.blob()
|
||||
return blob
|
||||
} catch (error) {
|
||||
console.error("Failed to capture preview:", error)
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCopySnapshot = useCallback(async () => {
|
||||
setIsCopying(true)
|
||||
try {
|
||||
const blob = await capturePreview()
|
||||
if (!blob) {
|
||||
throw new Error("Failed to capture image")
|
||||
}
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"image/png": blob,
|
||||
}),
|
||||
])
|
||||
|
||||
setCopied(true)
|
||||
toast.success("Snapshot copied to clipboard!")
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error)
|
||||
toast.error("Failed to copy snapshot. Try downloading instead.")
|
||||
} finally {
|
||||
setIsCopying(false)
|
||||
}
|
||||
}, [capturePreview])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
try {
|
||||
const blob = await capturePreview()
|
||||
if (!blob) {
|
||||
throw new Error("Failed to capture image")
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `supermemory-graph-${Date.now()}.png`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success("Snapshot downloaded!")
|
||||
} catch (error) {
|
||||
console.error("Failed to download:", error)
|
||||
toast.error("Failed to download snapshot")
|
||||
}
|
||||
}, [capturePreview])
|
||||
|
||||
const handleShareTwitter = useCallback(async () => {
|
||||
const text = encodeURIComponent(
|
||||
"Check out my knowledge graph on supermemory! 🧠\n\nhttps://supermemory.ai",
|
||||
)
|
||||
window.open(`https://twitter.com/intent/tweet?text=${text}`, "_blank")
|
||||
}, [])
|
||||
|
||||
const handleShareLinkedIn = useCallback(async () => {
|
||||
const url = encodeURIComponent("https://supermemory.ai")
|
||||
window.open(
|
||||
`https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
|
||||
"_blank",
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleShareInstagram = useCallback(async () => {
|
||||
// Instagram doesn't have a direct share URL, so we'll download and show a message
|
||||
await handleDownload()
|
||||
toast.info("Image downloaded! You can now share it on Instagram.")
|
||||
}, [handleDownload])
|
||||
|
||||
const handleClose = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[706px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-2">
|
||||
<DialogHeader className="flex-1">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-base text-center",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Share snapshot of your supermemory
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogPrimitive.Close
|
||||
onClick={handleClose}
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4 text-[#737373]" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative w-full aspect-[674/505] rounded-[14px] overflow-hidden"
|
||||
style={{
|
||||
boxShadow: "inset 2.42px 2.42px 4.26316px rgba(11, 15, 21, 0.7)",
|
||||
}}
|
||||
>
|
||||
<BackgroundGradient theme={selectedTheme} />
|
||||
|
||||
{/* Branding header */}
|
||||
<div className="absolute top-4 left-4 flex items-center gap-2 z-10">
|
||||
<GradientLogo className="w-7 h-6" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-white/70 leading-tight">
|
||||
{userName}
|
||||
</span>
|
||||
<span className="text-sm text-white font-semibold leading-tight">
|
||||
supermemory
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph canvas placeholder - will show the actual graph */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{graphCanvasRef?.current ? (
|
||||
<img
|
||||
src={graphCanvasRef.current.toDataURL("image/png")}
|
||||
alt="Memory graph"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-white/30 text-sm">
|
||||
Graph preview will appear here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Theme selectors */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeButton
|
||||
theme="gradient"
|
||||
isSelected={selectedTheme === "gradient"}
|
||||
onClick={() => setSelectedTheme("gradient")}
|
||||
/>
|
||||
<ThemeButton
|
||||
theme="dark-gradient"
|
||||
isSelected={selectedTheme === "dark-gradient"}
|
||||
onClick={() => setSelectedTheme("dark-gradient")}
|
||||
/>
|
||||
<ThemeButton
|
||||
theme="black"
|
||||
isSelected={selectedTheme === "black"}
|
||||
onClick={() => setSelectedTheme("black")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleCopySnapshot}
|
||||
disabled={isCopying}
|
||||
className={cn(
|
||||
"h-8 px-3 rounded-full text-sm font-normal gap-1.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background: "#0D121A",
|
||||
border: "1px solid rgba(115, 115, 115, 0.2)",
|
||||
boxShadow: "inset 1.5px 1.5px 4.5px rgba(0, 0, 0, 0.7)",
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="size-4 text-green-500" />
|
||||
<span className="text-green-500">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-white">Copy snapshot</span>
|
||||
<Copy className="size-4 text-[#737373]" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<SocialButton
|
||||
icon={<Download className="size-4 text-[#737373]" />}
|
||||
onClick={handleDownload}
|
||||
label="Download"
|
||||
/>
|
||||
<SocialButton
|
||||
icon={<XIcon2 className="size-4" />}
|
||||
onClick={handleShareTwitter}
|
||||
label="Share on X"
|
||||
/>
|
||||
<SocialButton
|
||||
icon={<LinkedInIcon className="size-4" />}
|
||||
onClick={handleShareLinkedIn}
|
||||
label="Share on LinkedIn"
|
||||
/>
|
||||
<SocialButton
|
||||
icon={<InstagramIcon className="size-[13px]" />}
|
||||
onClick={handleShareInstagram}
|
||||
label="Share on Instagram"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -55,7 +55,6 @@ export function TextMorph({
|
|||
}
|
||||
|
||||
return (
|
||||
// @ts-expect-error - style is optional
|
||||
<Component className={cn(className)} aria-label={children} style={style}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{characters.map((character) => (
|
||||
|
|
|
|||
|
|
@ -61,17 +61,20 @@
|
|||
"@tiptap/react": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@tiptap/suggestion": "^3.15.3",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"ai": "^6.0.35",
|
||||
"autumn-js": "0.0.116",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"dotenv": "^16.6.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"flubber": "^0.4.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"is-hotkey": "^0.2.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
|
|
|
|||
12
apps/web/public/icons/share-graph.svg
Normal file
12
apps/web/public/icons/share-graph.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.91667 2H3.16667C2.85725 2 2.5605 2.12292 2.34171 2.34171C2.12292 2.5605 2 2.85725 2 3.16667V4.91667M12.5 4.91667V3.16667C12.5 2.85725 12.3771 2.5605 12.1583 2.34171C11.9395 2.12292 11.6428 2 11.3333 2H9.58333M9.58333 12.5H11.3333C11.6428 12.5 11.9395 12.3771 12.1583 12.1583C12.3771 11.9395 12.5 11.6428 12.5 11.3333V9.58333M2 9.58333V11.3333C2 11.6428 2.12292 11.9395 2.34171 12.1583C2.5605 12.3771 2.85725 12.5 3.16667 12.5H4.91667" stroke="#FAFAFA" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g clip-path="url(#clip0_970_66955)">
|
||||
<path d="M10.7 6.65965H8.21414V4.49896H7.41098V6.84334C7.41098 7.09234 7.50927 7.33147 7.68399 7.50767L9.71374 9.55465L10.2816 8.98194L8.78248 7.47009H10.7004V6.66012L10.7 6.65965Z" fill="#FAFAFA"/>
|
||||
<path d="M4.42037 5.49397L5.91951 7.00584H4.00156V7.81581H6.48739V9.97648H7.29054V7.63211C7.29054 7.3831 7.19225 7.14396 7.01754 6.96779L4.98825 4.92126L4.42037 5.49397Z" fill="#FAFAFA"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_970_66955">
|
||||
<rect width="6.75627" height="5.47806" fill="white" transform="translate(4 4.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -68,7 +68,6 @@ const getDocumentIcon = (type: string) => {
|
|||
return <PDF {...iconProps} />
|
||||
default:
|
||||
{
|
||||
/*@ts-expect-error */
|
||||
}
|
||||
return <FileText {...iconProps} />
|
||||
}
|
||||
|
|
@ -109,7 +108,6 @@ export const NodeDetailPanel = memo(function NodeDetailPanel({
|
|||
{isDocument ? (
|
||||
getDocumentIcon((data as DocumentWithMemories).type ?? "")
|
||||
) : (
|
||||
// @ts-expect-error
|
||||
<Brain className={styles.headerIconMemory} />
|
||||
)}
|
||||
<HeadingH3Bold>{isDocument ? "Document" : "Memory"}</HeadingH3Bold>
|
||||
|
|
|
|||
|
|
@ -31,10 +31,18 @@ function Badge({
|
|||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
data-slot="badge"
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
<span
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
data-slot="badge"
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,18 @@ function BreadcrumbLink({
|
|||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
data-slot="breadcrumb-link"
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
<a
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
data-slot="breadcrumb-link"
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -54,10 +54,18 @@ function Button({
|
|||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
data-slot="button"
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
data-slot="button"
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -397,15 +397,26 @@ function SidebarGroupLabel({
|
|||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
const classes = cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
);
|
||||
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={classes}
|
||||
data-sidebar="group-label"
|
||||
data-slot="sidebar-group-label"
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
<div
|
||||
className={classes}
|
||||
data-sidebar="group-label"
|
||||
data-slot="sidebar-group-label"
|
||||
{...props}
|
||||
|
|
@ -418,17 +429,27 @@ function SidebarGroupAction({
|
|||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const classes = cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
);
|
||||
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={classes}
|
||||
data-sidebar="group-action"
|
||||
data-slot="sidebar-group-action"
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
<button
|
||||
className={classes}
|
||||
data-sidebar="group-action"
|
||||
data-slot="sidebar-group-action"
|
||||
{...props}
|
||||
|
|
@ -507,18 +528,21 @@ function SidebarMenuButton({
|
|||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
data-active={isActive}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-slot="sidebar-menu-button"
|
||||
{...props}
|
||||
/>
|
||||
const buttonProps = {
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
"data-active": isActive,
|
||||
"data-sidebar": "menu-button",
|
||||
"data-size": size,
|
||||
"data-slot": "sidebar-menu-button",
|
||||
...props,
|
||||
};
|
||||
|
||||
const button = asChild ? (
|
||||
<Slot {...(buttonProps as any)} />
|
||||
) : (
|
||||
<button {...buttonProps} />
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
|
|
@ -553,22 +577,32 @@ function SidebarMenuAction({
|
|||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const classes = cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
);
|
||||
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={classes}
|
||||
data-sidebar="menu-action"
|
||||
data-slot="sidebar-menu-action"
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
<button
|
||||
className={classes}
|
||||
data-sidebar="menu-action"
|
||||
data-slot="sidebar-menu-action"
|
||||
{...props}
|
||||
|
|
@ -676,18 +710,31 @@ function SidebarMenuSubButton({
|
|||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
const classes = cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
);
|
||||
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={classes}
|
||||
data-active={isActive}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
<a
|
||||
className={classes}
|
||||
data-active={isActive}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue