diff options
| author | nexxeln <[email protected]> | 2025-11-22 07:04:05 +0000 |
|---|---|---|
| committer | nexxeln <[email protected]> | 2025-11-22 07:04:05 +0000 |
| commit | 895f37ac899597dc66c40fb94f9e5bb43d60a42a (patch) | |
| tree | d0825db4ba52cdf5f404058135a8f88961f77a6a /packages/memory-graph/src | |
| parent | package the graph (#563) (diff) | |
| download | supermemory-proxy-graph-requests.tar.xz supermemory-proxy-graph-requests.zip | |
runtime styles injection + let user proxy requests for data in graph package + new playground (#588)proxy-graph-requests
Diffstat (limited to 'packages/memory-graph/src')
| -rw-r--r-- | packages/memory-graph/src/components/memory-graph-wrapper.tsx | 198 | ||||
| -rw-r--r-- | packages/memory-graph/src/components/memory-graph.tsx | 27 | ||||
| -rw-r--r-- | packages/memory-graph/src/hooks/use-documents-query.ts | 113 | ||||
| -rw-r--r-- | packages/memory-graph/src/index.tsx | 31 | ||||
| -rw-r--r-- | packages/memory-graph/src/lib/api-client.ts | 213 | ||||
| -rw-r--r-- | packages/memory-graph/src/lib/inject-styles.ts | 36 | ||||
| -rw-r--r-- | packages/memory-graph/src/types.ts | 42 |
7 files changed, 86 insertions, 574 deletions
diff --git a/packages/memory-graph/src/components/memory-graph-wrapper.tsx b/packages/memory-graph/src/components/memory-graph-wrapper.tsx deleted file mode 100644 index cfc8e148..00000000 --- a/packages/memory-graph/src/components/memory-graph-wrapper.tsx +++ /dev/null @@ -1,198 +0,0 @@ -"use client"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef } from "react"; -import { - flattenDocuments, - getLoadedCount, - getTotalDocuments, - useInfiniteDocumentsQuery, -} from "@/hooks/use-documents-query"; -import { MemoryGraph } from "./memory-graph"; -import { defaultTheme } from "@/styles/theme.css"; -import type { ApiClientError } from "@/lib/api-client"; - -export interface MemoryGraphWrapperProps { - /** API key for authentication */ - apiKey: string; - /** Optional base URL for the API (defaults to https://api.supermemory.ai) */ - baseUrl?: string; - /** Optional document ID to filter by */ - id?: string; - /** Visual variant - console for full view, consumer for embedded */ - variant?: "console" | "consumer"; - /** Show/hide the spaces filter dropdown */ - showSpacesSelector?: boolean; - /** Optional container tags to filter documents */ - containerTags?: string[]; - /** Callback when data fetching fails */ - onError?: (error: ApiClientError) => void; - /** Callback when data is successfully loaded */ - onSuccess?: (totalDocuments: number) => void; - /** Empty state content */ - children?: React.ReactNode; - /** Documents to highlight */ - highlightDocumentIds?: string[]; - /** Whether highlights are visible */ - highlightsVisible?: boolean; - /** Pixels occluded on the right side of the viewport */ - occludedRightPx?: number; -} - -/** - * Internal component that uses the query hooks - */ -function MemoryGraphWithQuery(props: MemoryGraphWrapperProps) { - const { - apiKey, - baseUrl, - containerTags, - variant = "console", - showSpacesSelector, - onError, - onSuccess, - children, - highlightDocumentIds, - highlightsVisible, - occludedRightPx, - } = props; - - // Derive showSpacesSelector from variant if not explicitly provided - // console variant shows spaces selector, consumer variant hides it - const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console"); - - // Use infinite query for automatic pagination - const { - data, - isLoading, - isFetchingNextPage, - hasNextPage, - fetchNextPage, - error, - } = useInfiniteDocumentsQuery({ - apiKey, - baseUrl, - containerTags, - enabled: !!apiKey, - }); - - // Flatten documents from all pages - const documents = useMemo(() => flattenDocuments(data), [data]); - const totalLoaded = useMemo(() => getLoadedCount(data), [data]); - const totalDocuments = useMemo(() => getTotalDocuments(data), [data]); - - // Eagerly load all pages to ensure complete graph data - const isLoadingAllPages = useRef(false); - - useEffect(() => { - // Only start loading once, when initial data is loaded - if (isLoading || isLoadingAllPages.current || !data?.pages?.[0]) return; - - const abortController = new AbortController(); - - // Start recursive page loading - const loadAllPages = async () => { - isLoadingAllPages.current = true; - - try { - // Keep fetching until no more pages or aborted - let shouldContinue = hasNextPage; - - while (shouldContinue && !abortController.signal.aborted) { - const result = await fetchNextPage(); - shouldContinue = result.hasNextPage ?? false; - - // Throttle requests to avoid overwhelming server (50ms delay like console app) - if (shouldContinue && !abortController.signal.aborted) { - await new Promise(resolve => setTimeout(resolve, 50)); - } - } - } catch (error) { - if (!abortController.signal.aborted) { - console.error('[MemoryGraph] Error loading pages:', error); - } - } - }; - - if (hasNextPage) { - loadAllPages(); - } - - // Cleanup on unmount - return () => { - abortController.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Only run once on mount - - // Call callbacks - if (error && onError) { - onError(error as ApiClientError); - } - - if (data && onSuccess && totalDocuments > 0) { - onSuccess(totalDocuments); - } - - // Load more function - const loadMoreDocuments = async () => { - if (hasNextPage && !isFetchingNextPage) { - await fetchNextPage(); - } - }; - - return ( - <MemoryGraph - documents={documents} - isLoading={isLoading} - isLoadingMore={isFetchingNextPage} - error={error as Error | null} - totalLoaded={totalLoaded} - hasMore={hasNextPage ?? false} - loadMoreDocuments={loadMoreDocuments} - variant={variant} - showSpacesSelector={finalShowSpacesSelector} - highlightDocumentIds={highlightDocumentIds} - highlightsVisible={highlightsVisible} - occludedRightPx={occludedRightPx} - autoLoadOnViewport={true} - themeClassName={defaultTheme} - > - {children} - </MemoryGraph> - ); -} - -// Create a default query client for the wrapper -const defaultQueryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - refetchOnMount: false, - retry: 2, - }, - }, -}); - -/** - * MemoryGraph component with built-in data fetching - * - * This component handles all data fetching internally using the provided API key. - * Simply pass your API key and it will fetch and render the graph automatically. - * - * @example - * ```tsx - * <MemoryGraphWrapper - * apiKey="your-api-key" - * variant="console" - * onError={(error) => console.error(error)} - * /> - * ``` - */ -export function MemoryGraphWrapper(props: MemoryGraphWrapperProps) { - return ( - <QueryClientProvider client={defaultQueryClient}> - <MemoryGraphWithQuery {...props} /> - </QueryClientProvider> - ); -} diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 3eeed37b..21d4a08f 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -6,23 +6,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GraphCanvas } from "./graph-canvas"; import { useGraphData } from "@/hooks/use-graph-data"; import { useGraphInteractions } from "@/hooks/use-graph-interactions"; +import { injectStyles } from "@/lib/inject-styles"; import { Legend } from "./legend"; import { LoadingIndicator } from "./loading-indicator"; import { NavigationControls } from "./navigation-controls"; import { NodeDetailPanel } from "./node-detail-panel"; import { SpacesDropdown } from "./spaces-dropdown"; import * as styles from "./memory-graph.css"; +import { defaultTheme } from "@/styles/theme.css"; import type { MemoryGraphProps } from "@/types"; export const MemoryGraph = ({ children, documents, - isLoading, - isLoadingMore, - error, + isLoading = false, + isLoadingMore = false, + error = null, totalLoaded, - hasMore, + hasMore = false, loadMoreDocuments, showSpacesSelector, variant = "console", @@ -33,6 +35,15 @@ export const MemoryGraph = ({ autoLoadOnViewport = true, themeClassName, }: MemoryGraphProps) => { + // Inject styles on first render (client-side only) + useEffect(() => { + injectStyles(); + }, []); + + // Derive totalLoaded from documents if not provided + const effectiveTotalLoaded = totalLoaded ?? documents.length; + // No-op for loadMoreDocuments if not provided + const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {}); // Derive showSpacesSelector from variant if not explicitly provided // console variant shows spaces selector, consumer variant hides it const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console"); @@ -293,7 +304,7 @@ export const MemoryGraph = ({ // If 80% or more of documents are visible, load more const visibilityRatio = visibleDocuments.length / data.documents.length; if (visibilityRatio >= 0.8) { - loadMoreDocuments(); + effectiveLoadMoreDocuments(); } }, [ isLoadingMore, @@ -305,7 +316,7 @@ export const MemoryGraph = ({ containerSize.width, containerSize.height, nodes, - loadMoreDocuments, + effectiveLoadMoreDocuments, ]); // Throttled version to avoid excessive checks @@ -352,7 +363,7 @@ export const MemoryGraph = ({ } return ( - <div className={themeClassName ? `${themeClassName} ${styles.mainContainer}` : styles.mainContainer}> + <div className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`}> {/* Spaces selector - only shown for console */} {finalShowSpacesSelector && availableSpaces.length > 0 && ( <div className={styles.spacesSelectorContainer}> @@ -369,7 +380,7 @@ export const MemoryGraph = ({ <LoadingIndicator isLoading={isLoading} isLoadingMore={isLoadingMore} - totalLoaded={totalLoaded} + totalLoaded={effectiveTotalLoaded} variant={variant} /> diff --git a/packages/memory-graph/src/hooks/use-documents-query.ts b/packages/memory-graph/src/hooks/use-documents-query.ts deleted file mode 100644 index eb9ab892..00000000 --- a/packages/memory-graph/src/hooks/use-documents-query.ts +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { fetchDocuments, type FetchDocumentsOptions } from "@/lib/api-client"; -import type { DocumentsResponse } from "@/api-types"; - -export interface UseDocumentsQueryOptions { - apiKey: string; - baseUrl?: string; - id?: string; // Optional document ID to filter by - containerTags?: string[]; - limit?: number; - sort?: "createdAt" | "updatedAt"; - order?: "asc" | "desc"; - enabled?: boolean; // Whether to enable the query -} - -/** - * Hook for fetching a single page of documents - * Useful when you don't need pagination - */ -export function useDocumentsQuery(options: UseDocumentsQueryOptions) { - const { - apiKey, - baseUrl, - containerTags, - limit = 50, - sort = "createdAt", - order = "desc", - enabled = true, - } = options; - - return useQuery({ - queryKey: ["documents", { apiKey, baseUrl, containerTags, limit, sort, order }], - queryFn: async () => { - return fetchDocuments({ - apiKey, - baseUrl, - page: 1, - limit, - sort, - order, - containerTags, - }); - }, - enabled: enabled && !!apiKey, - staleTime: 1000 * 60 * 5, // 5 minutes - retry: 2, - }); -} - -/** - * Hook for fetching documents with infinite scroll/pagination support - * Automatically handles loading more pages - */ -export function useInfiniteDocumentsQuery(options: UseDocumentsQueryOptions) { - const { - apiKey, - baseUrl, - containerTags, - limit = 500, - sort = "createdAt", - order = "desc", - enabled = true, - } = options; - - return useInfiniteQuery({ - queryKey: ["documents", "infinite", { apiKey, baseUrl, containerTags, limit, sort, order }], - queryFn: async ({ pageParam = 1 }) => { - return fetchDocuments({ - apiKey, - baseUrl, - page: pageParam, - limit, - sort, - order, - containerTags, - }); - }, - initialPageParam: 1, - getNextPageParam: (lastPage: DocumentsResponse) => { - const { currentPage, totalPages } = lastPage.pagination; - return currentPage < totalPages ? currentPage + 1 : undefined; - }, - enabled: enabled && !!apiKey, - staleTime: 1000 * 60 * 5, // 5 minutes - retry: 2, - }); -} - -/** - * Helper to flatten infinite query results into a single documents array - */ -export function flattenDocuments(data: { pages: DocumentsResponse[] } | undefined) { - if (!data?.pages) return []; - return data.pages.flatMap((page) => page.documents); -} - -/** - * Helper to get total documents count from infinite query - */ -export function getTotalDocuments(data: { pages: DocumentsResponse[] } | undefined) { - if (!data?.pages?.[0]) return 0; - return data.pages[0].pagination.totalItems; -} - -/** - * Helper to get current loaded count from infinite query - */ -export function getLoadedCount(data: { pages: DocumentsResponse[] } | undefined) { - if (!data?.pages) return 0; - return data.pages.reduce((sum, page) => sum + page.documents.length, 0); -} diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx index 1e413e00..6e5c882f 100644 --- a/packages/memory-graph/src/index.tsx +++ b/packages/memory-graph/src/index.tsx @@ -1,13 +1,11 @@ -// Auto-inject global styles (side effect import) -import "./styles"; - // Export the main component -export { MemoryGraphWrapper as MemoryGraph } from "./components/memory-graph-wrapper"; +export { MemoryGraph } from "./components/memory-graph"; + +// Export style injector for manual use if needed +export { injectStyles } from "./lib/inject-styles"; // Export types for consumers -export type { - MemoryGraphWrapperProps as MemoryGraphProps, -} from "./components/memory-graph-wrapper"; +export type { MemoryGraphProps } from "./types"; export type { DocumentWithMemories, @@ -21,25 +19,6 @@ export type { MemoryRelation, } from "./types"; -// Export API client for advanced usage -export { - fetchDocuments, - fetchDocumentsPage, - validateApiKey, - type FetchDocumentsOptions, - type ApiClientError, -} from "./lib/api-client"; - -// Export hooks for advanced usage (if users want to bring their own QueryClient) -export { - useDocumentsQuery, - useInfiniteDocumentsQuery, - flattenDocuments, - getTotalDocuments, - getLoadedCount, - type UseDocumentsQueryOptions, -} from "./hooks/use-documents-query"; - // Export theme system for custom theming export { themeContract, defaultTheme } from "./styles/theme.css"; export { sprinkles } from "./styles/sprinkles.css"; diff --git a/packages/memory-graph/src/lib/api-client.ts b/packages/memory-graph/src/lib/api-client.ts deleted file mode 100644 index faef4d06..00000000 --- a/packages/memory-graph/src/lib/api-client.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { DocumentsResponse } from "@/api-types"; - -export interface FetchDocumentsOptions { - apiKey: string; - baseUrl?: string; - page?: number; - limit?: number; - sort?: "createdAt" | "updatedAt"; - order?: "asc" | "desc"; - containerTags?: string[]; - signal?: AbortSignal; -} - -export interface ApiClientError extends Error { - status?: number; - statusText?: string; - response?: unknown; -} - -/** - * Creates an API client error with additional context - */ -function createApiError( - message: string, - status?: number, - statusText?: string, - response?: unknown, -): ApiClientError { - const error = new Error(message) as ApiClientError; - error.name = "ApiClientError"; - error.status = status; - error.statusText = statusText; - error.response = response; - return error; -} - -/** - * Fetches documents with their memory entries from the Supermemory API - * - * @param options - Configuration options for the API request - * @returns Promise resolving to the documents response - * @throws ApiClientError if the request fails - */ -export async function fetchDocuments( - options: FetchDocumentsOptions, -): Promise<DocumentsResponse> { - const { - apiKey, - baseUrl = "https://api.supermemory.ai", - page = 1, - limit = 50, - sort = "createdAt", - order = "desc", - containerTags, - signal, - } = options; - - // Validate required parameters - if (!apiKey) { - throw createApiError("API key is required"); - } - - // Construct the full URL - const url = `${baseUrl}/v3/documents/documents`; - - // Build request body - const body: { - page: number; - limit: number; - sort: string; - order: string; - containerTags?: string[]; - } = { - page, - limit, - sort, - order, - }; - - if (containerTags && containerTags.length > 0) { - body.containerTags = containerTags; - } - - try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify(body), - signal, - }); - - // Handle non-OK responses - if (!response.ok) { - let errorMessage = `Failed to fetch documents: ${response.status} ${response.statusText}`; - let errorResponse: unknown; - - try { - errorResponse = await response.json(); - if ( - errorResponse && - typeof errorResponse === "object" && - "message" in errorResponse - ) { - errorMessage = `API Error: ${(errorResponse as { message: string }).message}`; - } - } catch { - // If response is not JSON, use default error message - } - - throw createApiError( - errorMessage, - response.status, - response.statusText, - errorResponse, - ); - } - - // Parse and validate response - const data = await response.json(); - - // Basic validation of response structure - if (!data || typeof data !== "object") { - throw createApiError("Invalid response format: expected an object"); - } - - if (!("documents" in data) || !Array.isArray(data.documents)) { - throw createApiError( - "Invalid response format: missing documents array", - ); - } - - if (!("pagination" in data) || typeof data.pagination !== "object") { - throw createApiError( - "Invalid response format: missing pagination object", - ); - } - - return data as DocumentsResponse; - } catch (error) { - // Re-throw ApiClientError as-is - if ((error as ApiClientError).name === "ApiClientError") { - throw error; - } - - // Handle network errors - if (error instanceof TypeError && error.message.includes("fetch")) { - throw createApiError( - `Network error: Unable to connect to ${baseUrl}. Please check your internet connection.`, - ); - } - - // Handle abort errors - if (error instanceof Error && error.name === "AbortError") { - throw createApiError("Request was aborted"); - } - - // Handle other errors - throw createApiError( - `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } -} - -/** - * Fetches a single page of documents (convenience wrapper) - */ -export async function fetchDocumentsPage( - apiKey: string, - page: number, - baseUrl?: string, - signal?: AbortSignal, -): Promise<DocumentsResponse> { - return fetchDocuments({ - apiKey, - baseUrl, - page, - limit: 50, - signal, - }); -} - -/** - * Validates an API key by making a test request - * - * @param apiKey - The API key to validate - * @param baseUrl - Optional base URL for the API - * @returns Promise resolving to true if valid, false otherwise - */ -export async function validateApiKey( - apiKey: string, - baseUrl?: string, -): Promise<boolean> { - try { - await fetchDocuments({ - apiKey, - baseUrl, - page: 1, - limit: 1, - }); - return true; - } catch (error) { - // Check if it's an authentication error - if ((error as ApiClientError).status === 401) { - return false; - } - // Other errors might indicate valid key but other issues - // We'll return true in those cases to not block the user - return true; - } -} diff --git a/packages/memory-graph/src/lib/inject-styles.ts b/packages/memory-graph/src/lib/inject-styles.ts new file mode 100644 index 00000000..1a6bf4eb --- /dev/null +++ b/packages/memory-graph/src/lib/inject-styles.ts @@ -0,0 +1,36 @@ +/** + * Runtime CSS injection for universal bundler support + * The CSS content is injected by the build plugin + */ + +// This will be replaced by the build plugin with the actual CSS content +declare const __MEMORY_GRAPH_CSS__: string; + +// Track injection state +let injected = false; + +/** + * Inject memory-graph styles into the document head. + * Safe to call multiple times - will only inject once. + */ +export function injectStyles(): void { + // Only run in browser + if (typeof document === "undefined") return; + + // Only inject once + if (injected) return; + + // Check if already injected (e.g., by another instance) + if (document.querySelector('style[data-memory-graph]')) { + injected = true; + return; + } + + injected = true; + + // Create and inject style element + const style = document.createElement("style"); + style.setAttribute("data-memory-graph", ""); + style.textContent = __MEMORY_GRAPH_CSS__; + document.head.appendChild(style); +} diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index 796c8d01..0a80df22 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -73,27 +73,37 @@ export interface GraphCanvasProps { } export interface MemoryGraphProps { - children?: React.ReactNode; + /** The documents to display in the graph */ documents: DocumentWithMemories[]; - isLoading: boolean; - isLoadingMore: boolean; - error: Error | null; - totalLoaded: number; - hasMore: boolean; - loadMoreDocuments: () => Promise<void>; - // App-specific props - showSpacesSelector?: boolean; // true for console, false for consumer - variant?: "console" | "consumer"; // for different positioning and styling - legendId?: string; // Optional ID for the legend component - // Optional document highlight list (document custom IDs) + /** Whether the initial data is loading */ + isLoading?: boolean; + /** Error that occurred during data fetching */ + error?: Error | null; + /** Optional children to render when no documents exist */ + children?: React.ReactNode; + /** Whether more data is being loaded (for pagination) */ + isLoadingMore?: boolean; + /** Total number of documents loaded */ + totalLoaded?: number; + /** Whether there are more documents to load */ + hasMore?: boolean; + /** Callback to load more documents (for pagination) */ + loadMoreDocuments?: () => Promise<void>; + /** Show/hide the spaces filter dropdown */ + showSpacesSelector?: boolean; + /** Visual variant - "console" for full view, "consumer" for embedded */ + variant?: "console" | "consumer"; + /** Optional ID for the legend component */ + legendId?: string; + /** Document IDs to highlight in the graph */ highlightDocumentIds?: string[]; - // Whether highlights are currently visible (e.g., chat open) + /** Whether highlights are currently visible */ highlightsVisible?: boolean; - // Pixels occluded on the right side of the viewport (e.g., chat panel) + /** Pixels occluded on the right side of the viewport */ occludedRightPx?: number; - // Whether to auto-load more documents based on viewport visibility + /** Whether to auto-load more documents based on viewport visibility */ autoLoadOnViewport?: boolean; - // Theme class name to apply + /** Theme class name to apply */ themeClassName?: string; } |