diff options
| author | nexxeln <[email protected]> | 2025-11-19 18:57:55 +0000 |
|---|---|---|
| committer | nexxeln <[email protected]> | 2025-11-19 18:57:56 +0000 |
| commit | 5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb (patch) | |
| tree | 60336fd37b41e3597065729d098877483eba73b6 /packages | |
| parent | Fix: Prevent multiple prompts while AI response is generated (fixes #538) (#583) (diff) | |
| download | supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.tar.xz supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.zip | |
package the graph (#563)shoubhit/eng-358-packaging-graph-component
includes:
- a package that contains a MemoryGraph component which handles fetching data and rendering the graph
- a playground to test the package
problems:
- the bundle size is huge
- the styles are kinda broken? we are using [https://www.npmjs.com/package/vite-plugin-libgi-inject-css](https://www.npmjs.com/package/vite-plugin-lib-inject-css) to inject the styles

Diffstat (limited to 'packages')
65 files changed, 7178 insertions, 24 deletions
diff --git a/packages/memory-graph-playground/.gitignore b/packages/memory-graph-playground/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/packages/memory-graph-playground/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/memory-graph-playground/README.md b/packages/memory-graph-playground/README.md new file mode 100644 index 00000000..0480a389 --- /dev/null +++ b/packages/memory-graph-playground/README.md @@ -0,0 +1,47 @@ +# Memory Graph Playground + +A local development playground for testing the `@supermemory/memory-graph` package. + +## Getting Started + +1. Make sure the memory-graph package is built: + ```bash + cd ../memory-graph + bun run build + ``` + +2. Start the dev server: + ```bash + cd ../memory-graph-playground + bun run dev + ``` + +3. Open your browser to the URL shown (usually http://localhost:5173) + +4. Enter your Supermemory API key and click "Load Graph" + +## Features to Test + +- ✅ API key authentication +- ✅ Data fetching with React Query +- ✅ Graph rendering with PixiJS +- ✅ Interactive pan and zoom +- ✅ Node selection and details +- ✅ Space filtering +- ✅ Legend with statistics +- ✅ CSS auto-injection +- ✅ Error handling + +## Development + +When making changes to the memory-graph package: + +1. Make your changes in `packages/memory-graph/src` +2. Rebuild: `cd packages/memory-graph && bun run build` +3. The playground will hot-reload automatically + +## Notes + +- This playground uses the workspace version of `@supermemory/memory-graph` +- CSS is automatically injected from the package +- All dependencies are bundled except React/ReactDOM diff --git a/packages/memory-graph-playground/eslint.config.js b/packages/memory-graph-playground/eslint.config.js new file mode 100644 index 00000000..b19330b1 --- /dev/null +++ b/packages/memory-graph-playground/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/packages/memory-graph-playground/index.html b/packages/memory-graph-playground/index.html new file mode 100644 index 00000000..7542b7d5 --- /dev/null +++ b/packages/memory-graph-playground/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>memory-graph-playground</title> + </head> + <body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> + </body> +</html> diff --git a/packages/memory-graph-playground/package.json b/packages/memory-graph-playground/package.json new file mode 100644 index 00000000..203b066d --- /dev/null +++ b/packages/memory-graph-playground/package.json @@ -0,0 +1,31 @@ +{ + "name": "memory-graph-playground", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@supermemory/memory-graph": "workspace:*", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/node": "^24.6.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.45.0", + "vite": "^7.1.7" + } +} diff --git a/packages/memory-graph-playground/public/vite.svg b/packages/memory-graph-playground/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/packages/memory-graph-playground/public/vite.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file diff --git a/packages/memory-graph-playground/src/App.css b/packages/memory-graph-playground/src/App.css new file mode 100644 index 00000000..9e07ed68 --- /dev/null +++ b/packages/memory-graph-playground/src/App.css @@ -0,0 +1,119 @@ +.app { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + background: #0f1419; + color: #e2e8f0; +} + +.header { + padding: 1.5rem; + background: #1a1f29; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.header h1 { + margin: 0; + font-size: 2rem; + color: #fff; +} + +.header p { + margin: 0.5rem 0 0 0; + color: #94a3b8; +} + +.controls { + padding: 1rem 1.5rem; + display: flex; + gap: 1rem; + background: #1a1f29; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.api-key-input { + flex: 1; + padding: 0.75rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 0.5rem; + background: rgba(255, 255, 255, 0.05); + color: #e2e8f0; + font-size: 1rem; +} + +.api-key-input::placeholder { + color: #64748b; +} + +.api-key-input:focus { + outline: none; + border-color: #60a5fa; +} + +.load-button { + padding: 0.75rem 2rem; + border: none; + border-radius: 0.5rem; + background: #3b82f6; + color: white; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.load-button:hover:not(:disabled) { + background: #2563eb; +} + +.load-button:disabled { + background: #334155; + cursor: not-allowed; + opacity: 0.5; +} + +.graph-container { + flex: 1; + position: relative; + overflow: hidden; + padding: 1rem; +} + +.instructions { + flex: 1; + padding: 3rem; + max-width: 800px; + margin: 0 auto; +} + +.instructions h2 { + color: #fff; + margin-bottom: 1.5rem; +} + +.instructions h3 { + color: #e2e8f0; + margin-top: 2rem; + margin-bottom: 1rem; +} + +.instructions ol, +.instructions ul { + text-align: left; + line-height: 2; +} + +.instructions li { + margin-bottom: 0.5rem; + color: #cbd5e1; +} + +.instructions a { + color: #60a5fa; + text-decoration: none; +} + +.instructions a:hover { + text-decoration: underline; +} diff --git a/packages/memory-graph-playground/src/App.tsx b/packages/memory-graph-playground/src/App.tsx new file mode 100644 index 00000000..3ab8c82c --- /dev/null +++ b/packages/memory-graph-playground/src/App.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { MemoryGraph } from '@supermemory/memory-graph' +import './App.css' + +function App() { + const [apiKey, setApiKey] = useState('') + const [showGraph, setShowGraph] = useState(false) + + return ( + <div className="app"> + <div className="header"> + <h1>Memory Graph Playground</h1> + <p>Test the @supermemory/memory-graph package</p> + </div> + + <div className="controls"> + <input + type="password" + placeholder="Enter your Supermemory API key" + value={apiKey} + onChange={(e) => setApiKey(e.target.value)} + className="api-key-input" + /> + <button + onClick={() => setShowGraph(true)} + disabled={!apiKey} + className="load-button" + > + Load Graph + </button> + </div> + + {showGraph && apiKey && ( + <div className="graph-container"> + <MemoryGraph + apiKey={apiKey} + variant="console" + onError={(error) => { + console.error('Graph error:', error) + alert(`Error: ${error.message}`) + }} + onSuccess={(total) => { + console.log(`Loaded ${total} documents`) + }} + /> + </div> + )} + + {!showGraph && ( + <div className="instructions"> + <h2>Instructions</h2> + <ol> + <li>Get your API key from <a href="https://supermemory.ai" target="_blank" rel="noopener noreferrer">supermemory.ai</a></li> + <li>Enter your API key above</li> + <li>Click "Load Graph" to visualize your memories</li> + </ol> + <h3>Features to test:</h3> + <ul> + <li>Pan and zoom the graph</li> + <li>Click on nodes to see details</li> + <li>Drag nodes around</li> + <li>Use the space selector to filter by space</li> + <li>Check the legend for statistics</li> + </ul> + </div> + )} + </div> + ) +} + +export default App diff --git a/packages/memory-graph-playground/src/assets/react.svg b/packages/memory-graph-playground/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/packages/memory-graph-playground/src/assets/react.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
\ No newline at end of file diff --git a/packages/memory-graph-playground/src/index.css b/packages/memory-graph-playground/src/index.css new file mode 100644 index 00000000..b2876d8a --- /dev/null +++ b/packages/memory-graph-playground/src/index.css @@ -0,0 +1,31 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: dark; + color: rgba(255, 255, 255, 0.87); + background-color: #0f1419; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +#root { + width: 100%; + height: 100vh; +} diff --git a/packages/memory-graph-playground/src/main.tsx b/packages/memory-graph-playground/src/main.tsx new file mode 100644 index 00000000..cd61d6d1 --- /dev/null +++ b/packages/memory-graph-playground/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + <StrictMode> + <App /> + {/*<div>hi</div>*/} + </StrictMode>, +) diff --git a/packages/memory-graph-playground/tsconfig.app.json b/packages/memory-graph-playground/tsconfig.app.json new file mode 100644 index 00000000..a9b5a59c --- /dev/null +++ b/packages/memory-graph-playground/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/packages/memory-graph-playground/tsconfig.json b/packages/memory-graph-playground/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/packages/memory-graph-playground/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/packages/memory-graph-playground/tsconfig.node.json b/packages/memory-graph-playground/tsconfig.node.json new file mode 100644 index 00000000..8a67f62f --- /dev/null +++ b/packages/memory-graph-playground/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/memory-graph-playground/vite.config.ts b/packages/memory-graph-playground/vite.config.ts new file mode 100644 index 00000000..8b0f57b9 --- /dev/null +++ b/packages/memory-graph-playground/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/memory-graph/.gitignore b/packages/memory-graph/.gitignore new file mode 100644 index 00000000..37e17b72 --- /dev/null +++ b/packages/memory-graph/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules + +# Build output +dist +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.production + +# Editor +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Testing +coverage +.nyc_output diff --git a/packages/memory-graph/.npmignore b/packages/memory-graph/.npmignore new file mode 100644 index 00000000..da9b208a --- /dev/null +++ b/packages/memory-graph/.npmignore @@ -0,0 +1,42 @@ +# Source files (only publish dist) +src +*.ts +!*.d.ts + +# Config files +tsconfig.json +vite.config.ts +.gitignore +.npmignore + +# Development +node_modules +*.log +.env* + +# Editor +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage +.nyc_output +*.test.* +*.spec.* + +# CI/CD +.github +.gitlab-ci.yml +.travis.yml + +# Docs (except README) +docs +examples +.changeset diff --git a/packages/memory-graph/README.md b/packages/memory-graph/README.md new file mode 100644 index 00000000..3dd6be60 --- /dev/null +++ b/packages/memory-graph/README.md @@ -0,0 +1,224 @@ +# @supermemory/memory-graph + +> Interactive graph visualization component for Supermemory - visualize and explore your memory connections + +[](https://www.npmjs.com/package/@supermemory/memory-graph) +[](https://opensource.org/licenses/MIT) + +## Features + +- 🎨 **WebGL-powered rendering** - Smooth performance with hundreds of nodes using PixiJS +- 🔍 **Interactive exploration** - Pan, zoom, drag nodes, and explore connections +- 🧠 **Semantic connections** - Visualizes relationships based on content similarity +- 📱 **Responsive design** - Works seamlessly on mobile and desktop +- 🎯 **Zero configuration** - Works out of the box with automatic CSS injection +- 📦 **Lightweight** - Tree-shakeable and optimized bundle +- 🎭 **TypeScript** - Full TypeScript support with exported types + +## Installation + +```bash +npm install @supermemory/memory-graph +# or +yarn add @supermemory/memory-graph +# or +pnpm add @supermemory/memory-graph +# or +bun add @supermemory/memory-graph +``` + +## Quick Start + +```tsx +import { MemoryGraph } from '@supermemory/memory-graph' + +function App() { + return ( + <MemoryGraph + apiKey="your-api-key" + id="optional-document-id" + /> + ) +} +``` + +That's it! The CSS is automatically injected, no manual imports needed. + +## Usage + +### Basic Usage + +```tsx +import { MemoryGraph } from '@supermemory/memory-graph' + +<MemoryGraph + apiKey="your-supermemory-api-key" + variant="console" +/> +``` + +### Advanced Usage + +```tsx +import { MemoryGraph } from '@supermemory/memory-graph' + +<MemoryGraph + apiKey="your-api-key" + id="document-123" + baseUrl="https://api.supermemory.ai" + variant="consumer" + showSpacesSelector={true} + onError={(error) => { + console.error('Failed to load graph:', error) + }} + onSuccess={(data) => { + console.log('Graph loaded:', data) + }} +/> +``` + +## API Reference + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `apiKey` | `string` | **required** | Your Supermemory API key | +| `id` | `string` | `undefined` | Optional document ID to filter the graph | +| `baseUrl` | `string` | `"https://api.supermemory.ai"` | API base URL | +| `variant` | `"console" \| "consumer"` | `"console"` | Visual variant - console for full view, consumer for embedded | +| `showSpacesSelector` | `boolean` | `true` | Show/hide the spaces filter dropdown | +| `onError` | `(error: Error) => void` | `undefined` | Callback when data fetching fails | +| `onSuccess` | `(data: any) => void` | `undefined` | Callback when data is successfully loaded | + +## Framework Integration + +### Next.js + +```tsx +// app/graph/page.tsx +'use client' + +import { MemoryGraph } from '@supermemory/memory-graph' + +export default function GraphPage() { + return ( + <div className="w-full h-screen"> + <MemoryGraph apiKey={process.env.NEXT_PUBLIC_SUPERMEMORY_API_KEY!} /> + </div> + ) +} +``` + +### Vite/React + +```tsx +// src/App.tsx +import { MemoryGraph } from '@supermemory/memory-graph' + +function App() { + return ( + <div style={{ width: '100vw', height: '100vh' }}> + <MemoryGraph apiKey={import.meta.env.VITE_SUPERMEMORY_API_KEY} /> + </div> + ) +} +``` + +### Create React App + +```tsx +// src/App.tsx +import { MemoryGraph } from '@supermemory/memory-graph' + +function App() { + return ( + <div style={{ width: '100vw', height: '100vh' }}> + <MemoryGraph apiKey={process.env.REACT_APP_SUPERMEMORY_API_KEY} /> + </div> + ) +} +``` + +## Getting an API Key + +1. Visit [supermemory.ai](https://supermemory.ai) +2. Sign up or log in to your account +3. Navigate to Settings > API Keys +4. Generate a new API key +5. Copy and use it in your application + +⚠️ **Security Note**: Never commit API keys to version control. Use environment variables. + +## Features in Detail + +### WebGL Rendering + +The graph uses PixiJS for hardware-accelerated WebGL rendering, enabling smooth interaction with hundreds of nodes and connections. + +### Semantic Similarity + +Connections between memories are visualized based on semantic similarity, with stronger connections appearing more prominent. + +### Interactive Controls + +- **Pan**: Click and drag the background +- **Zoom**: Mouse wheel or pinch on mobile +- **Select Node**: Click on any document or memory +- **Drag Nodes**: Click and drag individual nodes +- **Fit to View**: Auto-fit button to center all content + +### Touch Support + +Full support for touch gestures including pinch-to-zoom and touch-drag for mobile devices. + +## Browser Support + +- Chrome/Edge (latest) +- Firefox (latest) +- Safari (latest) +- Mobile browsers with WebGL support + +## Requirements + +- React 18+ +- Modern browser with WebGL support + +## Development + +```bash +# Install dependencies +bun install + +# Build the package +bun run build + +# Watch mode for development +bun run dev + +# Type checking +bun run check-types +``` + +## License + +MIT © [Supermemory](https://supermemory.ai) + +## Support + +- 📧 Email: [email protected] +- 🐛 Issues: [GitHub Issues](https://github.com/supermemoryai/supermemory/issues) +- 💬 Discord: [Join our community](https://discord.gg/supermemory) + +## Roadmap + +- [ ] Custom theme support +- [ ] Export graph as image +- [ ] Advanced filtering options +- [ ] Graph animation presets +- [ ] Accessibility improvements +- [ ] Collaboration features + +--- + +Made with ❤️ by the Supermemory team diff --git a/packages/memory-graph/package.json b/packages/memory-graph/package.json new file mode 100644 index 00000000..6ffd7317 --- /dev/null +++ b/packages/memory-graph/package.json @@ -0,0 +1,81 @@ +{ + "name": "@supermemory/memory-graph", + "version": "0.1.0", + "description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections", + "type": "module", + "main": "./dist/memory-graph.cjs", + "module": "./dist/memory-graph.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/memory-graph.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/memory-graph.cjs" + } + }, + "./styles.css": "./dist/memory-graph.css", + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "sideEffects": [ + "**/*.css" + ], + "scripts": { + "dev": "vite build --watch", + "build": "vite build && tsc --emitDeclarationOnly", + "check-types": "tsc --noEmit", + "prepublishOnly": "bun run build" + }, + "keywords": [ + "supermemory", + "graph", + "visualization", + "memory", + "interactive", + "canvas", + "react", + "component" + ], + "author": "Supermemory", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/supermemoryai/supermemory", + "directory": "packages/memory-graph" + }, + "bugs": { + "url": "https://github.com/supermemoryai/supermemory/issues" + }, + "homepage": "https://github.com/supermemoryai/supermemory/tree/main/packages/memory-graph#readme", + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "dependencies": { + "@emotion/is-prop-valid": "^1.4.0", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-slot": "^1.2.4", + "@tanstack/react-query": "^5.90.7", + "@vanilla-extract/css": "^1.17.4", + "@vanilla-extract/recipes": "^0.5.7", + "@vanilla-extract/sprinkles": "^1.6.5", + "lucide-react": "^0.552.0", + "motion": "^12.23.24" + }, + "devDependencies": { + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vanilla-extract/vite-plugin": "^5.1.1", + "@vitejs/plugin-react": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^7.2.1", + "vite-plugin-lib-inject-css": "^2.2.2" + } +} diff --git a/packages/memory-graph/src/api-types.ts b/packages/memory-graph/src/api-types.ts new file mode 100644 index 00000000..7742e39f --- /dev/null +++ b/packages/memory-graph/src/api-types.ts @@ -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; + }; +} diff --git a/packages/memory-graph/src/assets/icons.tsx b/packages/memory-graph/src/assets/icons.tsx new file mode 100644 index 00000000..5383f690 --- /dev/null +++ b/packages/memory-graph/src/assets/icons.tsx @@ -0,0 +1,208 @@ +export const OneDrive = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 256 165" + xmlns="http://www.w3.org/2000/svg" + > + <title>OneDrive</title> + <path + d="m154.66 110.682l52.842-50.534c-10.976-42.8-54.57-68.597-97.37-57.62a80 80 0 0 0-46.952 33.51c.817-.02 91.48 74.644 91.48 74.644" + fill="#0364B8" + /> + <path + d="m97.618 45.552l-.002.009a63.7 63.7 0 0 0-33.619-9.543c-.274 0-.544.017-.818.02C27.852 36.476-.432 65.47.005 100.798a63.97 63.97 0 0 0 11.493 35.798l79.165-9.915l60.694-48.94z" + fill="#0078D4" + /> + <path + d="M207.502 60.148a53 53 0 0 0-3.51-.131a51.8 51.8 0 0 0-20.61 4.254l-.002-.005l-32.022 13.475l35.302 43.607l63.11 15.341c13.62-25.283 4.164-56.82-21.12-70.44a52 52 0 0 0-21.148-6.1" + fill="#1490DF" + /> + <path + d="M11.498 136.596a63.91 63.91 0 0 0 52.5 27.417h139.994a51.99 51.99 0 0 0 45.778-27.323l-98.413-58.95z" + fill="#28A8EA" + /> + </svg> +); + +export const GoogleDrive = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 256 229" + xmlns="http://www.w3.org/2000/svg" + > + <title>Google Drive</title> + <path + d="m19.354 196.034l11.29 19.5c2.346 4.106 5.718 7.332 9.677 9.678q17.009-21.591 23.68-33.137q6.77-11.717 16.641-36.655q-26.604-3.502-40.32-3.502q-13.165 0-40.322 3.502c0 4.545 1.173 9.09 3.519 13.196z" + fill="#0066DA" + /> + <path + d="M215.681 225.212c3.96-2.346 7.332-5.572 9.677-9.677l4.692-8.064l22.434-38.855a26.57 26.57 0 0 0 3.518-13.196q-27.315-3.502-40.247-3.502q-13.899 0-40.248 3.502q9.754 25.075 16.422 36.655q6.724 11.683 23.752 33.137" + fill="#EA4335" + /> + <path + d="M128.001 73.311q19.68-23.768 27.125-36.655q5.996-10.377 13.196-33.137C164.363 1.173 159.818 0 155.126 0h-54.25C96.184 0 91.64 1.32 87.68 3.519q9.16 26.103 15.544 37.154q7.056 12.213 24.777 32.638" + fill="#00832D" + /> + <path + d="M175.36 155.42H80.642l-40.32 69.792c3.958 2.346 8.503 3.519 13.195 3.519h148.968c4.692 0 9.238-1.32 13.196-3.52z" + fill="#2684FC" + /> + <path + d="M128.001 73.311L87.681 3.52c-3.96 2.346-7.332 5.571-9.678 9.677L3.519 142.224A26.57 26.57 0 0 0 0 155.42h80.642z" + fill="#00AC47" + /> + <path + d="m215.242 77.71l-37.243-64.514c-2.345-4.106-5.718-7.331-9.677-9.677l-40.32 69.792l47.358 82.109h80.496c0-4.546-1.173-9.09-3.519-13.196z" + fill="#FFBA00" + /> + </svg> +); + +export const Notion = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 256 268" + xmlns="http://www.w3.org/2000/svg" + > + <title>Notion</title> + <path + d="M16.092 11.538L164.09.608c18.179-1.56 22.85-.508 34.28 7.801l47.243 33.282C253.406 47.414 256 48.975 256 55.207v182.527c0 11.439-4.155 18.205-18.696 19.24L65.44 267.378c-10.913.517-16.11-1.043-21.825-8.327L8.826 213.814C2.586 205.487 0 199.254 0 191.97V29.726c0-9.352 4.155-17.153 16.092-18.188" + fill="#FFF" + /> + <path d="M164.09.608L16.092 11.538C4.155 12.573 0 20.374 0 29.726v162.245c0 7.284 2.585 13.516 8.826 21.843l34.789 45.237c5.715 7.284 10.912 8.844 21.825 8.327l171.864-10.404c14.532-1.035 18.696-7.801 18.696-19.24V55.207c0-5.911-2.336-7.614-9.21-12.66l-1.185-.856L198.37 8.409C186.94.1 182.27-.952 164.09.608M69.327 52.22c-14.033.945-17.216 1.159-25.186-5.323L23.876 30.778c-2.06-2.086-1.026-4.69 4.163-5.207l142.274-10.395c11.947-1.043 18.17 3.12 22.842 6.758l24.401 17.68c1.043.525 3.638 3.637.517 3.637L71.146 52.095zm-16.36 183.954V81.222c0-6.767 2.077-9.887 8.3-10.413L230.02 60.93c5.724-.517 8.31 3.12 8.31 9.879v153.917c0 6.767-1.044 12.49-10.387 13.008l-161.487 9.361c-9.343.517-13.489-2.594-13.489-10.921M212.377 89.53c1.034 4.681 0 9.362-4.681 9.897l-7.783 1.542v114.404c-6.758 3.637-12.981 5.715-18.18 5.715c-8.308 0-10.386-2.604-16.609-10.396l-50.898-80.079v77.476l16.1 3.646s0 9.362-12.989 9.362l-35.814 2.077c-1.043-2.086 0-7.284 3.63-8.318l9.351-2.595V109.823l-12.98-1.052c-1.044-4.68 1.55-11.439 8.826-11.965l38.426-2.585l52.958 81.113v-71.76l-13.498-1.552c-1.043-5.733 3.111-9.896 8.3-10.404z" /> + </svg> +); + +export const GoogleDocs = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <title>Google Docs</title> + <path + d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818zm0-3.273H7.091V9.273h9.818zM14.727 6h6l-6-6z" + fill="currentColor" + /> + </svg> +); + +export const GoogleSheets = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <title>Google Sheets</title> + <path + d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6zm1.363 10.636h-3.41v1.91h3.41zm0 3.273h-3.41v1.91h3.41zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z" + fill="currentColor" + /> + </svg> +); + +export const GoogleSlides = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <title>Google Slides</title> + <path + d="M16.09 15.273H7.91v-4.637h8.18zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75zm-.363 2.523H6.545v7.363h10.91zm-2.728-5.979V6h6.001l-6-6v3.294z" + fill="currentColor" + /> + </svg> +); + +export const NotionDoc = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <title>Notion Doc</title> + <path + d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514c-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233l4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632" + fill="currentColor" + /> + </svg> +); + +export const MicrosoftWord = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <title>Microsoft Word</title> + <path + d="M23.004 1.5q.41 0 .703.293t.293.703v19.008q0 .41-.293.703t-.703.293H6.996q-.41 0-.703-.293T6 21.504V18H.996q-.41 0-.703-.293T0 17.004V6.996q0-.41.293-.703T.996 6H6V2.496q0-.41.293-.703t.703-.293zM6.035 11.203l1.442 4.735h1.64l1.57-7.876H9.036l-.937 4.653l-1.325-4.5H5.38l-1.406 4.523l-.938-4.675H1.312l1.57 7.874h1.641zM22.5 21v-3h-15v3zm0-4.5v-3.75H12v3.75zm0-5.25V7.5H12v3.75zm0-5.25V3h-15v3Z" + fill="currentColor" + /> + </svg> +); + +export const MicrosoftExcel = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <title>Microsoft Excel</title> + <path + d="M23 1.5q.41 0 .7.3q.3.29.3.7v19q0 .41-.3.7q-.29.3-.7.3H7q-.41 0-.7-.3q-.3-.29-.3-.7V18H1q-.41 0-.7-.3q-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h5V2.5q0-.41.3-.7q.29-.3.7-.3zM6 13.28l1.42 2.66h2.14l-2.38-3.87l2.34-3.8H7.46l-1.3 2.4l-.05.08l-.04.09l-.64-1.28l-.66-1.29H2.59l2.27 3.82l-2.48 3.85h2.16zM14.25 21v-3H7.5v3zm0-4.5v-3.75H12v3.75zm0-5.25V7.5H12v3.75zm0-5.25V3H7.5v3zm8.25 15v-3h-6.75v3zm0-4.5v-3.75h-6.75v3.75zm0-5.25V7.5h-6.75v3.75zm0-5.25V3h-6.75v3Z" + fill="currentColor" + /> + </svg> +); + +export const MicrosoftPowerpoint = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <title>Microsoft PowerPoint</title> + <path + d="M13.5 1.5q1.453 0 2.795.375t2.508 1.06t2.12 1.641q.956.955 1.641 2.121q.686 1.166 1.061 2.508T24 12t-.375 2.795t-1.06 2.508q-.686 1.166-1.641 2.12q-.955.956-2.121 1.641q-1.166.686-2.508 1.061T13.5 22.5q-1.29 0-2.52-.305q-1.23-.304-2.337-.884T6.58 19.893Q5.625 19.055 4.887 18H.997q-.411 0-.704-.293T0 17.004V6.996q0-.41.293-.703T.996 6h3.89q.739-1.055 1.694-1.893q.955-.837 2.063-1.418q1.107-.58 2.337-.884T13.5 1.5m.75 1.535v8.215h8.215q-.14-1.64-.826-3.076t-1.782-2.531q-1.095-1.096-2.537-1.782t-3.07-.826m-5.262 7.57q0-.68-.228-1.166q-.229-.486-.627-.79q-.399-.305-.938-.446q-.539-.14-1.172-.14H2.848v7.863h1.84v-2.742H5.93q.574 0 1.119-.17t.978-.493q.434-.322.698-.802t.263-1.114M13.5 21q1.172 0 2.262-.287t2.056-.82t1.776-1.278q.808-.744 1.418-1.664t.984-1.986q.375-1.067.469-2.227h-9.703V3.035q-1.735.14-3.27.908T6.797 6h4.207q.41 0 .703.293t.293.703v10.008q0 .41-.293.703t-.703.293H6.797q.644.715 1.412 1.271q.768.557 1.623.944t1.781.586T13.5 21M5.812 9.598q.575 0 .915.228q.34.229.34.838q0 .27-.124.44q-.123.17-.31.275q-.188.105-.422.146t-.445.041H4.687V9.598Z" + fill="currentColor" + /> + </svg> +); + +export const MicrosoftOneNote = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <title>Microsoft OneNote</title> + <path + d="M23 1.5q.41 0 .7.3q.3.29.3.7v19q0 .41-.3.7q-.29.3-.7.3H7q-.41 0-.7-.3q-.3-.29-.3-.7V18H1q-.41 0-.7-.3q-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h5V2.5q0-.41.3-.7q.29-.3.7-.3ZM4.56 11l2.83 4.93h1.79V8.07H7.44v5.03L4.71 8.07H2.82v7.86h1.74ZM22.5 21v-3h-3v3Zm0-4.5v-3h-3v3Zm0-4.5V9h-3v3Zm0-4.5V3h-15v3H11q.41 0 .7.3q.3.29.3.7v10q0 .41-.3.7q-.29.3-.7.3H7.5v3H18V7.5Z" + fill="currentColor" + /> + </svg> +); + +export const PDF = ({ className }: { className?: string }) => ( + <svg + className={className} + viewBox="0 0 15 16" + xmlns="http://www.w3.org/2000/svg" + > + <title>PDF</title> + <path + d="M3 13h.86v-.9h.39c.62 0 1.14-.45 1.14-1.06s-.5-1.05-1.14-1.05H3v3Zm.86-1.59v-.72h.3c.2 0 .37.13.37.35s-.16.36-.37.36h-.3ZM6.19 13h1.19c1 0 1.62-.59 1.62-1.52C9 10.61 8.38 10 7.38 10H6.19zm.86-.71V10.7h.29c.33 0 .78.16.78.78c0 .65-.45.81-.78.81zM10 13h.86v-1.07h1.06v-.69h-1.06v-.54h1.21v-.69h-2.06v3Z" + fill="currentColor" + /> + <path + d="M12.5 16h-10c-.83 0-1.5-.67-1.5-1.5v-13C1 .67 1.67 0 2.5 0h7.09c.4 0 .78.16 1.06.44l2.91 2.91c.28.28.44.66.44 1.06V14.5c0 .83-.67 1.5-1.5 1.5M2.5 1c-.28 0-.5.22-.5.5v13c0 .28.22.5.5.5h10c.28 0 .5-.22.5-.5V4.41a.47.47 0 0 0-.15-.35L9.94 1.15A.5.5 0 0 0 9.59 1z" + fill="currentColor" + /> + <path + d="M13.38 5h-2.91C9.66 5 9 4.34 9 3.53V.62c0-.28.22-.5.5-.5s.5.22.5.5v2.91c0 .26.21.47.47.47h2.91c.28 0 .5.22.5.5s-.22.5-.5.5" + fill="currentColor" + /> + </svg> +); diff --git a/packages/memory-graph/src/components/canvas-common.css.ts b/packages/memory-graph/src/components/canvas-common.css.ts new file mode 100644 index 00000000..91005488 --- /dev/null +++ b/packages/memory-graph/src/components/canvas-common.css.ts @@ -0,0 +1,10 @@ +import { style } from "@vanilla-extract/css"; + +/** + * Canvas wrapper/container that fills its parent + * Used by both graph-canvas and graph-webgl-canvas + */ +export const canvasWrapper = style({ + position: "absolute", + inset: 0, +}); diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx new file mode 100644 index 00000000..59efa74d --- /dev/null +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -0,0 +1,764 @@ +"use client"; + +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; +import { colors } from "@/constants"; +import type { + DocumentWithMemories, + GraphCanvasProps, + GraphNode, + MemoryEntry, +} from "@/types"; +import { canvasWrapper } from "./canvas-common.css"; + +export const GraphCanvas = memo<GraphCanvasProps>( + ({ + nodes, + edges, + panX, + panY, + zoom, + width, + height, + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragMove, + onNodeDragEnd, + onPanStart, + onPanMove, + onPanEnd, + onWheel, + onDoubleClick, + onTouchStart, + onTouchMove, + onTouchEnd, + draggingNodeId, + highlightDocumentIds, + }) => { + const canvasRef = useRef<HTMLCanvasElement>(null); + const animationRef = useRef<number>(0); + const startTimeRef = useRef<number>(Date.now()); + const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const currentHoveredNode = useRef<string | null>(null); + + // Initialize start time once + useEffect(() => { + startTimeRef.current = Date.now(); + }, []); + + // Efficient hit detection + const getNodeAtPosition = useCallback( + (x: number, y: number): string | null => { + // Check from top-most to bottom-most: memory nodes are drawn after documents + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]!; + const screenX = node.x * zoom + panX; + const screenY = node.y * zoom + panY; + const nodeSize = node.size * zoom; + + const dx = x - screenX; + const dy = y - screenY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance <= nodeSize / 2) { + return node.id; + } + } + return null; + }, + [nodes, panX, panY, zoom], + ); + + // Handle mouse events + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + mousePos.current = { x, y }; + + const nodeId = getNodeAtPosition(x, y); + if (nodeId !== currentHoveredNode.current) { + currentHoveredNode.current = nodeId; + onNodeHover(nodeId); + } + + // Handle node dragging + if (draggingNodeId) { + onNodeDragMove(e); + } + }, + [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const nodeId = getNodeAtPosition(x, y); + if (nodeId) { + // When starting a node drag, prevent initiating pan + e.stopPropagation(); + onNodeDragStart(nodeId, e); + return; + } + onPanStart(e); + }, + [getNodeAtPosition, onNodeDragStart, onPanStart], + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const nodeId = getNodeAtPosition(x, y); + if (nodeId) { + onNodeClick(nodeId); + } + }, + [getNodeAtPosition, onNodeClick], + ); + + // Professional rendering function with LOD + const render = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const currentTime = Date.now(); + const _elapsed = currentTime - startTimeRef.current; + + // Level-of-detail optimization based on zoom + const useSimplifiedRendering = zoom < 0.3; + + // Clear canvas + ctx.clearRect(0, 0, width, height); + + // Set high quality rendering + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + + // Draw minimal background grid + ctx.strokeStyle = "rgba(148, 163, 184, 0.03)"; // Very subtle grid + ctx.lineWidth = 1; + const gridSpacing = 100 * zoom; + const offsetX = panX % gridSpacing; + const offsetY = panY % gridSpacing; + + // Simple, clean grid lines + for (let x = offsetX; x < width; x += gridSpacing) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + for (let y = offsetY; y < height; y += gridSpacing) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + // Create node lookup map + const nodeMap = new Map(nodes.map((node) => [node.id, node])); + + // Draw enhanced edges with sophisticated styling + ctx.lineCap = "round"; + edges.forEach((edge) => { + const sourceNode = nodeMap.get(edge.source); + const targetNode = nodeMap.get(edge.target); + + if (sourceNode && targetNode) { + const sourceX = sourceNode.x * zoom + panX; + const sourceY = sourceNode.y * zoom + panY; + const targetX = targetNode.x * zoom + panX; + const targetY = targetNode.y * zoom + panY; + + // Enhanced viewport culling with edge type considerations + if ( + sourceX < -100 || + sourceX > width + 100 || + targetX < -100 || + targetX > width + 100 + ) { + return; + } + + // Skip very weak connections when zoomed out for performance + if (useSimplifiedRendering) { + if ( + edge.edgeType === "doc-memory" && + edge.visualProps.opacity < 0.3 + ) { + return; // Skip very weak doc-memory edges when zoomed out + } + } + + // Enhanced connection styling based on edge type + let connectionColor = colors.connection.weak; + let dashPattern: number[] = []; + let opacity = edge.visualProps.opacity; + let lineWidth = Math.max(1, edge.visualProps.thickness * zoom); + + if (edge.edgeType === "doc-memory") { + // Doc-memory: Solid thin lines, subtle + dashPattern = []; + connectionColor = colors.connection.memory; + opacity = 0.9; + lineWidth = 1; + } else if (edge.edgeType === "doc-doc") { + // Doc-doc: Thick dashed lines with strong similarity emphasis + dashPattern = useSimplifiedRendering ? [] : [10, 5]; // Solid lines when zoomed out + opacity = Math.max(0, edge.similarity * 0.5); + lineWidth = Math.max(1, edge.similarity * 2); // Thicker for stronger similarity + + if (edge.similarity > 0.85) + connectionColor = colors.connection.strong; + else if (edge.similarity > 0.725) + connectionColor = colors.connection.medium; + } else if (edge.edgeType === "version") { + // Version chains: Double line effect with relation-specific colors + dashPattern = []; + connectionColor = edge.color || colors.relations.updates; + opacity = 0.8; + lineWidth = 2; + } + + ctx.strokeStyle = connectionColor; + ctx.lineWidth = lineWidth; + ctx.globalAlpha = opacity; + ctx.setLineDash(dashPattern); + + if (edge.edgeType === "version") { + // Special double-line rendering for version chains + // First line (outer) + ctx.lineWidth = 3; + ctx.globalAlpha = opacity * 0.3; + ctx.beginPath(); + ctx.moveTo(sourceX, sourceY); + ctx.lineTo(targetX, targetY); + ctx.stroke(); + + // Second line (inner) + ctx.lineWidth = 1; + ctx.globalAlpha = opacity; + ctx.beginPath(); + ctx.moveTo(sourceX, sourceY); + ctx.lineTo(targetX, targetY); + ctx.stroke(); + } else { + // Simplified lines when zoomed out, curved when zoomed in + if (useSimplifiedRendering) { + // Straight lines for performance + ctx.beginPath(); + ctx.moveTo(sourceX, sourceY); + ctx.lineTo(targetX, targetY); + ctx.stroke(); + } else { + // Regular curved line for doc-memory and doc-doc + const midX = (sourceX + targetX) / 2; + const midY = (sourceY + targetY) / 2; + const dx = targetX - sourceX; + const dy = targetY - sourceY; + const distance = Math.sqrt(dx * dx + dy * dy); + const controlOffset = + edge.edgeType === "doc-memory" + ? 15 + : Math.min(30, distance * 0.2); + + ctx.beginPath(); + ctx.moveTo(sourceX, sourceY); + ctx.quadraticCurveTo( + midX + controlOffset * (dy / distance), + midY - controlOffset * (dx / distance), + targetX, + targetY, + ); + ctx.stroke(); + } + } + + // Subtle arrow head for version edges + if (edge.edgeType === "version") { + const angle = Math.atan2(targetY - sourceY, targetX - sourceX); + const arrowLength = Math.max(6, 8 * zoom); // Shorter, more subtle + const arrowWidth = Math.max(8, 12 * zoom); + + // Calculate arrow position offset from node edge + const nodeRadius = (targetNode.size * zoom) / 2; + const offsetDistance = nodeRadius + 2; + const arrowX = targetX - Math.cos(angle) * offsetDistance; + const arrowY = targetY - Math.sin(angle) * offsetDistance; + + ctx.save(); + ctx.translate(arrowX, arrowY); + ctx.rotate(angle); + ctx.setLineDash([]); + + // Simple outlined arrow (not filled) + ctx.strokeStyle = connectionColor; + ctx.lineWidth = Math.max(1, 1.5 * zoom); + ctx.globalAlpha = opacity; + + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(-arrowLength, arrowWidth / 2); + ctx.moveTo(0, 0); + ctx.lineTo(-arrowLength, -arrowWidth / 2); + ctx.stroke(); + + ctx.restore(); + } + } + }); + + ctx.globalAlpha = 1; + ctx.setLineDash([]); + + // Prepare highlight set from provided document IDs (customId or internal) + const highlightSet = new Set<string>(highlightDocumentIds ?? []); + + // Draw nodes with enhanced styling and LOD optimization + nodes.forEach((node) => { + const screenX = node.x * zoom + panX; + const screenY = node.y * zoom + panY; + const nodeSize = node.size * zoom; + + // Enhanced viewport culling + const margin = nodeSize + 50; + if ( + screenX < -margin || + screenX > width + margin || + screenY < -margin || + screenY > height + margin + ) { + return; + } + + const isHovered = currentHoveredNode.current === node.id; + const isDragging = node.isDragging; + const isHighlightedDocument = (() => { + if (node.type !== "document" || highlightSet.size === 0) return false; + const doc = node.data as DocumentWithMemories; + if (doc.customId && highlightSet.has(doc.customId)) return true; + return highlightSet.has(doc.id); + })(); + + if (node.type === "document") { + // Enhanced glassmorphism document styling + const docWidth = nodeSize * 1.4; + const docHeight = nodeSize * 0.9; + + // Multi-layer glass effect + ctx.fillStyle = isDragging + ? colors.document.accent + : isHovered + ? colors.document.secondary + : colors.document.primary; + ctx.globalAlpha = 1; + + // Enhanced border with subtle glow + ctx.strokeStyle = isDragging + ? colors.document.glow + : isHovered + ? colors.document.accent + : colors.document.border; + ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1; + + // Rounded rectangle with enhanced styling + const radius = useSimplifiedRendering ? 6 : 12; + ctx.beginPath(); + ctx.roundRect( + screenX - docWidth / 2, + screenY - docHeight / 2, + docWidth, + docHeight, + radius, + ); + ctx.fill(); + ctx.stroke(); + + // Subtle inner highlight for glass effect (skip when zoomed out) + if (!useSimplifiedRendering && (isHovered || isDragging)) { + ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect( + screenX - docWidth / 2 + 1, + screenY - docHeight / 2 + 1, + docWidth - 2, + docHeight - 2, + radius - 1, + ); + ctx.stroke(); + } + + // Highlight ring for search hits + if (isHighlightedDocument) { + ctx.save(); + ctx.globalAlpha = 0.9; + ctx.strokeStyle = colors.accent.primary; + ctx.lineWidth = 3; + ctx.setLineDash([6, 4]); + const ringPadding = 10; + ctx.beginPath(); + ctx.roundRect( + screenX - docWidth / 2 - ringPadding, + screenY - docHeight / 2 - ringPadding, + docWidth + ringPadding * 2, + docHeight + ringPadding * 2, + radius + 6, + ); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + } + } else { + // Enhanced memory styling with status indicators + const mem = node.data as MemoryEntry; + const isForgotten = + mem.isForgotten || + (mem.forgetAfter && + new Date(mem.forgetAfter).getTime() < Date.now()); + const isLatest = mem.isLatest; + + // Check if memory is expiring soon (within 7 days) + const expiringSoon = + mem.forgetAfter && + !isForgotten && + new Date(mem.forgetAfter).getTime() - Date.now() < + 1000 * 60 * 60 * 24 * 7; + + // Check if memory is new (created within last 24 hours) + const isNew = + !isForgotten && + new Date(mem.createdAt).getTime() > + Date.now() - 1000 * 60 * 60 * 24; + + // Determine colors based on status + let fillColor = colors.memory.primary; + let borderColor = colors.memory.border; + let glowColor = colors.memory.glow; + + if (isForgotten) { + fillColor = colors.status.forgotten; + borderColor = "rgba(220,38,38,0.3)"; + glowColor = "rgba(220,38,38,0.2)"; + } else if (expiringSoon) { + borderColor = colors.status.expiring; + glowColor = colors.accent.amber; + } else if (isNew) { + borderColor = colors.status.new; + glowColor = colors.accent.emerald; + } + + if (isDragging) { + fillColor = colors.memory.accent; + borderColor = glowColor; + } else if (isHovered) { + fillColor = colors.memory.secondary; + } + + const radius = nodeSize / 2; + + ctx.fillStyle = fillColor; + ctx.globalAlpha = isLatest ? 1 : 0.4; + ctx.strokeStyle = borderColor; + ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5; + + if (useSimplifiedRendering) { + // Simple circles when zoomed out for performance + ctx.beginPath(); + ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI); + ctx.fill(); + ctx.stroke(); + } else { + // HEXAGONAL memory nodes when zoomed in + const sides = 6; + ctx.beginPath(); + for (let i = 0; i < sides; i++) { + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; // Start from top + const x = screenX + radius * Math.cos(angle); + const y = screenY + radius * Math.sin(angle); + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Inner highlight for glass effect + if (isHovered || isDragging) { + ctx.strokeStyle = "rgba(147, 197, 253, 0.3)"; + ctx.lineWidth = 1; + const innerRadius = radius - 2; + ctx.beginPath(); + for (let i = 0; i < sides; i++) { + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; + const x = screenX + innerRadius * Math.cos(angle); + const y = screenY + innerRadius * Math.sin(angle); + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.closePath(); + ctx.stroke(); + } + } + + // Status indicators overlay (always preserve these as required) + if (isForgotten) { + // Cross for forgotten memories + ctx.strokeStyle = "rgba(220,38,38,0.4)"; + ctx.lineWidth = 2; + const r = nodeSize * 0.25; + ctx.beginPath(); + ctx.moveTo(screenX - r, screenY - r); + ctx.lineTo(screenX + r, screenY + r); + ctx.moveTo(screenX + r, screenY - r); + ctx.lineTo(screenX - r, screenY + r); + ctx.stroke(); + } else if (isNew) { + // Small dot for new memories + ctx.fillStyle = colors.status.new; + ctx.beginPath(); + ctx.arc( + screenX + nodeSize * 0.25, + screenY - nodeSize * 0.25, + Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px + 0, + 2 * Math.PI, + ); + ctx.fill(); + } + } + + // Enhanced hover glow effect (skip when zoomed out for performance) + if (!useSimplifiedRendering && (isHovered || isDragging)) { + const glowColor = + node.type === "document" + ? colors.document.glow + : colors.memory.glow; + + ctx.strokeStyle = glowColor; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.globalAlpha = 0.6; + + ctx.beginPath(); + const glowSize = nodeSize * 0.7; + if (node.type === "document") { + ctx.roundRect( + screenX - glowSize, + screenY - glowSize / 1.4, + glowSize * 2, + glowSize * 1.4, + 15, + ); + } else { + // Hexagonal glow for memory nodes + const glowRadius = glowSize; + const sides = 6; + for (let i = 0; i < sides; i++) { + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; + const x = screenX + glowRadius * Math.cos(angle); + const y = screenY + glowRadius * Math.sin(angle); + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.closePath(); + } + ctx.stroke(); + ctx.setLineDash([]); + } + }); + + ctx.globalAlpha = 1; + }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]); + + // Change-based rendering instead of continuous animation + const lastRenderParams = useRef<string>(""); + + // Create a render key that changes when visual state changes + const renderKey = useMemo(() => { + const nodePositions = nodes + .map( + (n) => + `${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`, + ) + .join("|"); + const highlightKey = (highlightDocumentIds ?? []).join("|"); + return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`; + }, [ + nodes, + edges.length, + panX, + panY, + zoom, + width, + height, + highlightDocumentIds, + ]); + + // Only render when something actually changed + useEffect(() => { + if (renderKey !== lastRenderParams.current) { + lastRenderParams.current = renderKey; + render(); + } + }, [renderKey, render]); + + // Cleanup any existing animation frames + useEffect(() => { + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, []); + + // Add native wheel event listener to prevent browser zoom + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const handleNativeWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Call the onWheel handler with a synthetic-like event + // @ts-expect-error - partial WheelEvent object + onWheel({ + deltaY: e.deltaY, + deltaX: e.deltaX, + clientX: e.clientX, + clientY: e.clientY, + currentTarget: canvas, + nativeEvent: e, + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.WheelEvent); + }; + + // Add listener with passive: false to ensure preventDefault works + canvas.addEventListener("wheel", handleNativeWheel, { passive: false }); + + // Also prevent gesture events for touch devices + const handleGesture = (e: Event) => { + e.preventDefault(); + }; + + canvas.addEventListener("gesturestart", handleGesture, { + passive: false, + }); + canvas.addEventListener("gesturechange", handleGesture, { + passive: false, + }); + canvas.addEventListener("gestureend", handleGesture, { passive: false }); + + return () => { + canvas.removeEventListener("wheel", handleNativeWheel); + canvas.removeEventListener("gesturestart", handleGesture); + canvas.removeEventListener("gesturechange", handleGesture); + canvas.removeEventListener("gestureend", handleGesture); + }; + }, [onWheel]); + + // High-DPI handling -------------------------------------------------- + const dpr = + typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; + + useLayoutEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + // upscale backing store + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + canvas.width = width * dpr; + canvas.height = height * dpr; + + const ctx = canvas.getContext("2d"); + ctx?.scale(dpr, dpr); + }, [width, height, dpr]); + // ----------------------------------------------------------------------- + + return ( + <canvas + className={canvasWrapper} + height={height} + onClick={handleClick} + onDoubleClick={onDoubleClick} + onMouseDown={handleMouseDown} + onMouseLeave={() => { + if (draggingNodeId) { + onNodeDragEnd(); + } else { + onPanEnd(); + } + }} + onMouseMove={(e) => { + handleMouseMove(e); + if (!draggingNodeId) { + onPanMove(e); + } + }} + onMouseUp={() => { + if (draggingNodeId) { + onNodeDragEnd(); + } else { + onPanEnd(); + } + }} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} + ref={canvasRef} + style={{ + cursor: draggingNodeId + ? "grabbing" + : currentHoveredNode.current + ? "grab" + : "move", + touchAction: "none", + userSelect: "none", + WebkitUserSelect: "none", + }} + width={width} + /> + ); + }, +); + +GraphCanvas.displayName = "GraphCanvas"; diff --git a/packages/memory-graph/src/components/legend.css.ts b/packages/memory-graph/src/components/legend.css.ts new file mode 100644 index 00000000..b758cf9d --- /dev/null +++ b/packages/memory-graph/src/components/legend.css.ts @@ -0,0 +1,345 @@ +import { style, styleVariants, globalStyle } from "@vanilla-extract/css"; +import { themeContract } from "../styles/theme.css"; + +/** + * Legend container base + */ +const legendContainerBase = style({ + position: "absolute", + zIndex: 20, // Above most elements but below node detail panel + borderRadius: themeContract.radii.xl, + overflow: "hidden", + width: "fit-content", + height: "fit-content", + maxHeight: "calc(100vh - 2rem)", // Prevent overflow +}); + +/** + * Legend container variants for positioning + * Console: Bottom-right (doesn't conflict with anything) + * Consumer: Bottom-right (moved from top to avoid conflicts) + */ +export const legendContainer = styleVariants({ + consoleDesktop: [ + legendContainerBase, + { + bottom: themeContract.space[4], + right: themeContract.space[4], + }, + ], + consoleMobile: [ + legendContainerBase, + { + bottom: themeContract.space[4], + right: themeContract.space[4], + "@media": { + "screen and (max-width: 767px)": { + display: "none", + }, + }, + }, + ], + consumerDesktop: [ + legendContainerBase, + { + // Changed from top to bottom to avoid overlap with node detail panel + bottom: themeContract.space[4], + right: themeContract.space[4], + }, + ], + consumerMobile: [ + legendContainerBase, + { + bottom: themeContract.space[4], + right: themeContract.space[4], + "@media": { + "screen and (max-width: 767px)": { + display: "none", + }, + }, + }, + ], +}); + +/** + * Mobile size variants + */ +export const mobileSize = styleVariants({ + expanded: { + maxWidth: "20rem", // max-w-xs + }, + collapsed: { + width: "4rem", // w-16 + height: "3rem", // h-12 + }, +}); + +/** + * Legend content wrapper + */ +export const legendContent = style({ + position: "relative", + zIndex: 10, +}); + +/** + * Collapsed trigger button + */ +export const collapsedTrigger = style({ + width: "100%", + height: "100%", + padding: themeContract.space[2], + display: "flex", + alignItems: "center", + justifyContent: "center", + transition: themeContract.transitions.normal, + + selectors: { + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.05)", + }, + }, +}); + +export const collapsedContent = style({ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: themeContract.space[1], +}); + +export const collapsedText = style({ + fontSize: themeContract.typography.fontSize.xs, + color: themeContract.colors.text.secondary, + fontWeight: themeContract.typography.fontWeight.medium, +}); + +export const collapsedIcon = style({ + width: "0.75rem", + height: "0.75rem", + color: themeContract.colors.text.muted, +}); + +/** + * Header + */ +export const legendHeader = style({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingLeft: themeContract.space[4], + paddingRight: themeContract.space[4], + paddingTop: themeContract.space[3], + paddingBottom: themeContract.space[3], + borderBottom: "1px solid rgba(71, 85, 105, 0.5)", // slate-600/50 +}); + +export const legendTitle = style({ + fontSize: themeContract.typography.fontSize.sm, + fontWeight: themeContract.typography.fontWeight.medium, + color: themeContract.colors.text.primary, +}); + +export const headerTrigger = style({ + padding: themeContract.space[1], + borderRadius: themeContract.radii.sm, + transition: themeContract.transitions.normal, + + selectors: { + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + }, + }, +}); + +export const headerIcon = style({ + width: "1rem", + height: "1rem", + color: themeContract.colors.text.muted, +}); + +/** + * Content sections + */ +export const sectionsContainer = style({ + fontSize: themeContract.typography.fontSize.xs, + color: themeContract.colors.text.secondary, + paddingLeft: themeContract.space[4], + paddingRight: themeContract.space[4], + paddingTop: themeContract.space[3], + paddingBottom: themeContract.space[3], +}); + +export const sectionWrapper = style({ + marginTop: themeContract.space[3], + selectors: { + "&:first-child": { + marginTop: 0, + }, + }, +}); + +export const sectionTitle = style({ + fontSize: themeContract.typography.fontSize.xs, + fontWeight: themeContract.typography.fontWeight.medium, + color: themeContract.colors.text.secondary, + textTransform: "uppercase", + letterSpacing: "0.05em", + marginBottom: themeContract.space[2], +}); + +export const itemsList = style({ + display: "flex", + flexDirection: "column", + gap: "0.375rem", // gap-1.5 +}); + +export const legendItem = style({ + display: "flex", + alignItems: "center", + gap: themeContract.space[2], +}); + +export const legendIcon = style({ + width: "0.75rem", + height: "0.75rem", + flexShrink: 0, +}); + +export const legendText = style({ + fontSize: themeContract.typography.fontSize.xs, +}); + +/** + * Shape styles + */ +export const hexagon = style({ + clipPath: "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", +}); + +export const documentNode = style({ + width: "1rem", + height: "0.75rem", + background: "rgba(255, 255, 255, 0.08)", + border: "1px solid rgba(255, 255, 255, 0.25)", + borderRadius: themeContract.radii.sm, + flexShrink: 0, +}); + +export const memoryNode = style([ + hexagon, + { + width: "0.75rem", + height: "0.75rem", + background: "rgba(147, 197, 253, 0.1)", + border: "1px solid rgba(147, 197, 253, 0.35)", + flexShrink: 0, + }, +]); + +export const memoryNodeOlder = style([ + memoryNode, + { + opacity: 0.4, + }, +]); + +export const forgottenNode = style([ + hexagon, + { + width: "0.75rem", + height: "0.75rem", + background: "rgba(239, 68, 68, 0.3)", + border: "1px solid rgba(239, 68, 68, 0.8)", + position: "relative", + flexShrink: 0, + }, +]); + +export const forgottenIcon = style({ + position: "absolute", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "rgb(248, 113, 113)", + fontSize: themeContract.typography.fontSize.xs, + lineHeight: "1", +}); + +export const expiringNode = style([ + hexagon, + { + width: "0.75rem", + height: "0.75rem", + background: "rgba(147, 197, 253, 0.1)", + border: "2px solid rgb(245, 158, 11)", + flexShrink: 0, + }, +]); + +export const newNode = style([ + hexagon, + { + width: "0.75rem", + height: "0.75rem", + background: "rgba(147, 197, 253, 0.1)", + border: "2px solid rgb(16, 185, 129)", + position: "relative", + flexShrink: 0, + }, +]); + +export const newBadge = style({ + position: "absolute", + top: "-0.25rem", + right: "-0.25rem", + width: "0.5rem", + height: "0.5rem", + backgroundColor: "rgb(16, 185, 129)", + borderRadius: themeContract.radii.full, +}); + +export const connectionLine = style({ + width: "1rem", + height: 0, + borderTop: "1px solid rgb(148, 163, 184)", + flexShrink: 0, +}); + +export const similarityLine = style({ + width: "1rem", + height: 0, + borderTop: "2px dashed rgb(148, 163, 184)", + flexShrink: 0, +}); + +export const relationLine = style({ + width: "1rem", + height: 0, + borderTop: "2px solid", + flexShrink: 0, +}); + +export const weakSimilarity = style({ + width: "0.75rem", + height: "0.75rem", + borderRadius: themeContract.radii.full, + background: "rgba(148, 163, 184, 0.2)", + flexShrink: 0, +}); + +export const strongSimilarity = style({ + width: "0.75rem", + height: "0.75rem", + borderRadius: themeContract.radii.full, + background: "rgba(148, 163, 184, 0.6)", + flexShrink: 0, +}); + +export const gradientCircle = style({ + width: "0.75rem", + height: "0.75rem", + background: "linear-gradient(to right, rgb(148, 163, 184), rgb(96, 165, 250))", + borderRadius: themeContract.radii.full, +}); diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx new file mode 100644 index 00000000..16f588a9 --- /dev/null +++ b/packages/memory-graph/src/components/legend.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useIsMobile } from "@/hooks/use-mobile"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/ui/collapsible"; +import { GlassMenuEffect } from "@/ui/glass-effect"; +import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react"; +import { memo, useEffect, useState } from "react"; +import { colors } from "@/constants"; +import type { GraphEdge, GraphNode, LegendProps } from "@/types"; +import * as styles from "./legend.css"; + +// 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; +} + +export const Legend = memo(function Legend({ + variant = "console", + id, + nodes = [], + edges = [], + isLoading = false, +}: ExtendedLegendProps) { + const isMobile = useIsMobile(); + const [isExpanded, setIsExpanded] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); + + // 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, expanded on desktop + setIsExpanded(!isMobile); + } + setIsInitialized(true); + } + }, [isInitialized, isMobile]); + + // Save to cookie when state changes + const handleToggleExpanded = (expanded: boolean) => { + setIsExpanded(expanded); + setCookie("legendCollapsed", expanded ? "false" : "true"); + }; + + // Get container class based on variant and mobile state + const getContainerClass = () => { + if (variant === "console") { + return isMobile ? styles.legendContainer.consoleMobile : styles.legendContainer.consoleDesktop; + } + return isMobile ? styles.legendContainer.consumerMobile : styles.legendContainer.consumerDesktop; + }; + + // Calculate stats + const memoryCount = nodes.filter((n) => n.type === "memory").length; + const documentCount = nodes.filter((n) => n.type === "document").length; + + const containerClass = isMobile && !isExpanded + ? `${getContainerClass()} ${styles.mobileSize.collapsed}` + : isMobile + ? `${getContainerClass()} ${styles.mobileSize.expanded}` + : getContainerClass(); + + return ( + <div + className={containerClass} + id={id} + > + <Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}> + {/* Glass effect background */} + <GlassMenuEffect rounded="xl" /> + + <div className={styles.legendContent}> + {/* Mobile and Desktop collapsed state */} + {!isExpanded && ( + <CollapsibleTrigger className={styles.collapsedTrigger}> + <div className={styles.collapsedContent}> + <div className={styles.collapsedText}>?</div> + <ChevronUp className={styles.collapsedIcon} /> + </div> + </CollapsibleTrigger> + )} + + {/* Expanded state */} + {isExpanded && ( + <> + {/* Header with toggle */} + <div className={styles.legendHeader}> + <div className={styles.legendTitle}>Legend</div> + <CollapsibleTrigger className={styles.headerTrigger}> + <ChevronDown className={styles.headerIcon} /> + </CollapsibleTrigger> + </div> + + <CollapsibleContent> + <div className={styles.sectionsContainer}> + {/* Stats Section */} + {!isLoading && ( + <div className={styles.sectionWrapper}> + <div className={styles.sectionTitle}> + Statistics + </div> + <div className={styles.itemsList}> + <div className={styles.legendItem}> + <Brain className={styles.legendIcon} style={{ color: "rgb(96, 165, 250)" }} /> + <span className={styles.legendText}> + {memoryCount} memories + </span> + </div> + <div className={styles.legendItem}> + <FileText className={styles.legendIcon} style={{ color: "rgb(203, 213, 225)" }} /> + <span className={styles.legendText}> + {documentCount} documents + </span> + </div> + <div className={styles.legendItem}> + <div className={styles.gradientCircle} /> + <span className={styles.legendText}> + {edges.length} connections + </span> + </div> + </div> + </div> + )} + + {/* Node Types */} + <div className={styles.sectionWrapper}> + <div className={styles.sectionTitle}> + Nodes + </div> + <div className={styles.itemsList}> + <div className={styles.legendItem}> + <div className={styles.documentNode} /> + <span className={styles.legendText}>Document</span> + </div> + <div className={styles.legendItem}> + <div className={styles.memoryNode} /> + <span className={styles.legendText}>Memory (latest)</span> + </div> + <div className={styles.legendItem}> + <div className={styles.memoryNodeOlder} /> + <span className={styles.legendText}>Memory (older)</span> + </div> + </div> + </div> + + {/* Status Indicators */} + <div className={styles.sectionWrapper}> + <div className={styles.sectionTitle}> + Status + </div> + <div className={styles.itemsList}> + <div className={styles.legendItem}> + <div className={styles.forgottenNode}> + <div className={styles.forgottenIcon}> + ✕ + </div> + </div> + <span className={styles.legendText}>Forgotten</span> + </div> + <div className={styles.legendItem}> + <div className={styles.expiringNode} /> + <span className={styles.legendText}>Expiring soon</span> + </div> + <div className={styles.legendItem}> + <div className={styles.newNode}> + <div className={styles.newBadge} /> + </div> + <span className={styles.legendText}>New memory</span> + </div> + </div> + </div> + + {/* Connection Types */} + <div className={styles.sectionWrapper}> + <div className={styles.sectionTitle}> + Connections + </div> + <div className={styles.itemsList}> + <div className={styles.legendItem}> + <div className={styles.connectionLine} /> + <span className={styles.legendText}>Doc → Memory</span> + </div> + <div className={styles.legendItem}> + <div className={styles.similarityLine} /> + <span className={styles.legendText}>Doc similarity</span> + </div> + </div> + </div> + + {/* Relation Types */} + <div className={styles.sectionWrapper}> + <div className={styles.sectionTitle}> + Relations + </div> + <div className={styles.itemsList}> + {[ + ["updates", colors.relations.updates], + ["extends", colors.relations.extends], + ["derives", colors.relations.derives], + ].map(([label, color]) => ( + <div className={styles.legendItem} key={label}> + <div + className={styles.relationLine} + style={{ borderColor: color }} + /> + <span + className={styles.legendText} + style={{ color: color, textTransform: "capitalize" }} + > + {label} + </span> + </div> + ))} + </div> + </div> + + {/* Similarity Strength */} + <div className={styles.sectionWrapper}> + <div className={styles.sectionTitle}> + Similarity + </div> + <div className={styles.itemsList}> + <div className={styles.legendItem}> + <div className={styles.weakSimilarity} /> + <span className={styles.legendText}>Weak</span> + </div> + <div className={styles.legendItem}> + <div className={styles.strongSimilarity} /> + <span className={styles.legendText}>Strong</span> + </div> + </div> + </div> + </div> + </CollapsibleContent> + </> + )} + </div> + </Collapsible> + </div> + ); +}); + +Legend.displayName = "Legend"; diff --git a/packages/memory-graph/src/components/loading-indicator.css.ts b/packages/memory-graph/src/components/loading-indicator.css.ts new file mode 100644 index 00000000..09010f28 --- /dev/null +++ b/packages/memory-graph/src/components/loading-indicator.css.ts @@ -0,0 +1,55 @@ +import { style } from "@vanilla-extract/css"; +import { themeContract } from "../styles/theme.css"; +import { animations } from "../styles"; + +/** + * Loading indicator container + * Positioned top-left, below spaces dropdown + */ +export const loadingContainer = style({ + position: "absolute", + zIndex: 30, // High priority so it's visible when loading + borderRadius: themeContract.radii.xl, + overflow: "hidden", + top: "5.5rem", // Below spaces dropdown (~88px) + left: themeContract.space[4], +}); + +/** + * Content wrapper + */ +export const loadingContent = style({ + position: "relative", + zIndex: 10, + color: themeContract.colors.text.secondary, + paddingLeft: themeContract.space[4], + paddingRight: themeContract.space[4], + paddingTop: themeContract.space[3], + paddingBottom: themeContract.space[3], +}); + +/** + * Flex container for icon and text + */ +export const loadingFlex = style({ + display: "flex", + alignItems: "center", + gap: themeContract.space[2], +}); + +/** + * Spinning icon + */ +export const loadingIcon = style({ + width: "1rem", + height: "1rem", + animation: `${animations.spin} 1s linear infinite`, + color: themeContract.colors.memory.border, +}); + +/** + * Loading text + */ +export const loadingText = style({ + fontSize: themeContract.typography.fontSize.sm, +}); diff --git a/packages/memory-graph/src/components/loading-indicator.tsx b/packages/memory-graph/src/components/loading-indicator.tsx new file mode 100644 index 00000000..be31430b --- /dev/null +++ b/packages/memory-graph/src/components/loading-indicator.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { GlassMenuEffect } from "@/ui/glass-effect"; +import { Sparkles } from "lucide-react"; +import { memo } from "react"; +import type { LoadingIndicatorProps } from "@/types"; +import { + loadingContainer, + loadingContent, + loadingFlex, + loadingIcon, + loadingText, +} from "./loading-indicator.css"; + +export const LoadingIndicator = memo<LoadingIndicatorProps>( + ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { + if (!isLoading && !isLoadingMore) return null; + + return ( + <div className={loadingContainer}> + {/* Glass effect background */} + <GlassMenuEffect rounded="xl" /> + + <div className={loadingContent}> + <div className={loadingFlex}> + {/*@ts-ignore */} + <Sparkles className={loadingIcon} /> + <span className={loadingText}> + {isLoading + ? "Loading memory graph..." + : `Loading more documents... (${totalLoaded})`} + </span> + </div> + </div> + </div> + ); + }, +); + +LoadingIndicator.displayName = "LoadingIndicator"; diff --git a/packages/memory-graph/src/components/memory-graph-wrapper.tsx b/packages/memory-graph/src/components/memory-graph-wrapper.tsx new file mode 100644 index 00000000..cfc8e148 --- /dev/null +++ b/packages/memory-graph/src/components/memory-graph-wrapper.tsx @@ -0,0 +1,198 @@ +"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.css.ts b/packages/memory-graph/src/components/memory-graph.css.ts new file mode 100644 index 00000000..f5b38273 --- /dev/null +++ b/packages/memory-graph/src/components/memory-graph.css.ts @@ -0,0 +1,75 @@ +import { style } from "@vanilla-extract/css"; +import { themeContract } from "../styles/theme.css"; + +/** + * Error state container + */ +export const errorContainer = style({ + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: themeContract.colors.background.primary, +}); + +export const errorCard = style({ + borderRadius: themeContract.radii.xl, + overflow: "hidden", +}); + +export const errorContent = style({ + position: "relative", + zIndex: 10, + color: themeContract.colors.text.secondary, + paddingLeft: themeContract.space[6], + paddingRight: themeContract.space[6], + paddingTop: themeContract.space[4], + paddingBottom: themeContract.space[4], +}); + +/** + * Main graph container + * Position relative so absolutely positioned children position relative to this container + */ +export const mainContainer = style({ + position: "relative", + height: "100%", + borderRadius: themeContract.radii.xl, + overflow: "hidden", + backgroundColor: themeContract.colors.background.primary, +}); + +/** + * Spaces selector positioning + * Top-left corner, below most overlays + */ +export const spacesSelectorContainer = style({ + position: "absolute", + top: themeContract.space[4], + left: themeContract.space[4], + zIndex: 15, // Above base elements, below loading/panels +}); + +/** + * Graph canvas container + */ +export const graphContainer = style({ + width: "100%", + height: "100%", + position: "relative", + overflow: "hidden", + touchAction: "none", + userSelect: "none", + WebkitUserSelect: "none", +}); + +/** + * Navigation controls positioning + * Bottom-left corner + */ +export const navControlsContainer = style({ + position: "absolute", + bottom: themeContract.space[4], + left: themeContract.space[4], + zIndex: 15, // Same level as spaces dropdown +}); diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx new file mode 100644 index 00000000..3eeed37b --- /dev/null +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { GlassMenuEffect } from "@/ui/glass-effect"; +import { AnimatePresence } from "motion/react"; +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 { 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 type { MemoryGraphProps } from "@/types"; + +export const MemoryGraph = ({ + children, + documents, + isLoading, + isLoadingMore, + error, + totalLoaded, + hasMore, + loadMoreDocuments, + showSpacesSelector, + variant = "console", + legendId, + highlightDocumentIds = [], + highlightsVisible = true, + occludedRightPx = 0, + autoLoadOnViewport = true, + themeClassName, +}: MemoryGraphProps) => { + // Derive showSpacesSelector from variant if not explicitly provided + // console variant shows spaces selector, consumer variant hides it + const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console"); + + const [selectedSpace, setSelectedSpace] = useState<string>("all"); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const containerRef = useRef<HTMLDivElement>(null); + + // Create data object with pagination to satisfy type requirements + const data = useMemo(() => { + return documents && documents.length > 0 + ? { + documents, + pagination: { + currentPage: 1, + limit: documents.length, + totalItems: documents.length, + totalPages: 1, + }, + } + : null; + }, [documents]); + + // Graph interactions with variant-specific settings + const { + panX, + panY, + zoom, + /** hoveredNode currently unused within this component */ + hoveredNode: _hoveredNode, + selectedNode, + draggingNodeId, + nodePositions, + handlePanStart, + handlePanMove, + handlePanEnd, + handleWheel, + handleNodeHover, + handleNodeClick, + handleNodeDragStart, + handleNodeDragMove, + handleNodeDragEnd, + handleDoubleClick, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + setSelectedNode, + autoFitToViewport, + centerViewportOn, + zoomIn, + zoomOut, + } = useGraphInteractions(variant); + + // Graph data + const { nodes, edges } = useGraphData( + data, + selectedSpace, + nodePositions, + draggingNodeId, + ); + + // Auto-fit once per unique highlight set to show the full graph for context + const lastFittedHighlightKeyRef = useRef<string>(""); + useEffect(() => { + const highlightKey = highlightsVisible + ? highlightDocumentIds.join("|") + : ""; + if ( + highlightKey && + highlightKey !== lastFittedHighlightKeyRef.current && + containerSize.width > 0 && + containerSize.height > 0 && + nodes.length > 0 + ) { + autoFitToViewport(nodes, containerSize.width, containerSize.height, { + occludedRightPx, + animate: true, + }); + lastFittedHighlightKeyRef.current = highlightKey; + } + }, [ + highlightsVisible, + highlightDocumentIds, + containerSize.width, + containerSize.height, + nodes.length, + occludedRightPx, + autoFitToViewport, + ]); + + // Auto-fit graph when component mounts or nodes change significantly + const hasAutoFittedRef = useRef(false); + useEffect(() => { + // Only auto-fit once when we have nodes and container size + if ( + !hasAutoFittedRef.current && + nodes.length > 0 && + containerSize.width > 0 && + containerSize.height > 0 + ) { + // Auto-fit to show all content for both variants + // Add a small delay to ensure the canvas is fully initialized + const timer = setTimeout(() => { + autoFitToViewport(nodes, containerSize.width, containerSize.height); + hasAutoFittedRef.current = true; + }, 100); + + return () => clearTimeout(timer); + } + }, [ + nodes, + containerSize.width, + containerSize.height, + autoFitToViewport, + ]); + + // Reset auto-fit flag when nodes array becomes empty (switching views) + useEffect(() => { + if (nodes.length === 0) { + hasAutoFittedRef.current = false; + } + }, [nodes.length]); + + // Extract unique spaces from memories and calculate counts + const { availableSpaces, spaceMemoryCounts } = useMemo(() => { + if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} }; + + const spaceSet = new Set<string>(); + const counts: Record<string, number> = {}; + + data.documents.forEach((doc) => { + doc.memoryEntries.forEach((memory) => { + const spaceId = memory.spaceContainerTag || memory.spaceId || "default"; + spaceSet.add(spaceId); + counts[spaceId] = (counts[spaceId] || 0) + 1; + }); + }); + + return { + availableSpaces: Array.from(spaceSet).sort(), + spaceMemoryCounts: counts, + }; + }, [data]); + + // Handle container resize + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + const newWidth = containerRef.current.clientWidth; + const newHeight = containerRef.current.clientHeight; + + // Only update if size actually changed and is valid + setContainerSize((prev) => { + if (prev.width !== newWidth || prev.height !== newHeight) { + return { width: newWidth, height: newHeight }; + } + return prev; + }); + } + }; + + // Use a slight delay to ensure DOM is fully rendered + const timer = setTimeout(updateSize, 0); + updateSize(); // Also call immediately + + window.addEventListener("resize", updateSize); + + // Use ResizeObserver for more accurate container size detection + const resizeObserver = new ResizeObserver(updateSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + clearTimeout(timer); + window.removeEventListener("resize", updateSize); + resizeObserver.disconnect(); + }; + }, []); + + // Enhanced node drag start that includes nodes data + const handleNodeDragStartWithNodes = useCallback( + (nodeId: string, e: React.MouseEvent) => { + handleNodeDragStart(nodeId, e, nodes); + }, + [handleNodeDragStart, nodes], + ); + + // Navigation callbacks + const handleCenter = useCallback(() => { + if (nodes.length > 0) { + // Calculate center of all nodes + let sumX = 0 + let sumY = 0 + let count = 0 + + nodes.forEach((node) => { + sumX += node.x + sumY += node.y + count++ + }) + + if (count > 0) { + const centerX = sumX / count + const centerY = sumY / count + centerViewportOn(centerX, centerY, containerSize.width, containerSize.height) + } + } + }, [nodes, centerViewportOn, containerSize.width, containerSize.height]) + + const handleAutoFit = useCallback(() => { + if (nodes.length > 0 && containerSize.width > 0 && containerSize.height > 0) { + autoFitToViewport(nodes, containerSize.width, containerSize.height, { + occludedRightPx, + animate: true, + }) + } + }, [nodes, containerSize.width, containerSize.height, occludedRightPx, autoFitToViewport]) + + // Get selected node data + const selectedNodeData = useMemo(() => { + if (!selectedNode) return null; + return nodes.find((n) => n.id === selectedNode) || null; + }, [selectedNode, nodes]); + + // Viewport-based loading: load more when most documents are visible (optional) + const checkAndLoadMore = useCallback(() => { + if ( + isLoadingMore || + !hasMore || + !data?.documents || + data.documents.length === 0 + ) + return; + + // Calculate viewport bounds + const viewportBounds = { + left: -panX / zoom - 200, + right: (-panX + containerSize.width) / zoom + 200, + top: -panY / zoom - 200, + bottom: (-panY + containerSize.height) / zoom + 200, + }; + + // Count visible documents + const visibleDocuments = data.documents.filter((doc) => { + const docNodes = nodes.filter( + (node) => node.type === "document" && node.data.id === doc.id, + ); + return docNodes.some( + (node) => + node.x >= viewportBounds.left && + node.x <= viewportBounds.right && + node.y >= viewportBounds.top && + node.y <= viewportBounds.bottom, + ); + }); + + // If 80% or more of documents are visible, load more + const visibilityRatio = visibleDocuments.length / data.documents.length; + if (visibilityRatio >= 0.8) { + loadMoreDocuments(); + } + }, [ + isLoadingMore, + hasMore, + data, + panX, + panY, + zoom, + containerSize.width, + containerSize.height, + nodes, + loadMoreDocuments, + ]); + + // Throttled version to avoid excessive checks + const lastLoadCheckRef = useRef(0); + const throttledCheckAndLoadMore = useCallback(() => { + const now = Date.now(); + if (now - lastLoadCheckRef.current > 1000) { + // Check at most once per second + lastLoadCheckRef.current = now; + checkAndLoadMore(); + } + }, [checkAndLoadMore]); + + // Monitor viewport changes to trigger loading + useEffect(() => { + if (!autoLoadOnViewport) return; + throttledCheckAndLoadMore(); + }, [throttledCheckAndLoadMore, autoLoadOnViewport]); + + // Initial load trigger when graph is first rendered + useEffect(() => { + if (!autoLoadOnViewport) return; + if (data?.documents && data.documents.length > 0 && hasMore) { + // Start loading more documents after initial render + setTimeout(() => { + throttledCheckAndLoadMore(); + }, 500); // Small delay to allow initial layout + } + }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]); + + if (error) { + return ( + <div className={styles.errorContainer}> + <div className={styles.errorCard}> + {/* Glass effect background */} + <GlassMenuEffect rounded="xl" /> + + <div className={styles.errorContent}> + Error loading documents: {error.message} + </div> + </div> + </div> + ); + } + + return ( + <div className={themeClassName ? `${themeClassName} ${styles.mainContainer}` : styles.mainContainer}> + {/* Spaces selector - only shown for console */} + {finalShowSpacesSelector && availableSpaces.length > 0 && ( + <div className={styles.spacesSelectorContainer}> + <SpacesDropdown + availableSpaces={availableSpaces} + onSpaceChange={setSelectedSpace} + selectedSpace={selectedSpace} + spaceMemoryCounts={spaceMemoryCounts} + /> + </div> + )} + + {/* Loading indicator */} + <LoadingIndicator + isLoading={isLoading} + isLoadingMore={isLoadingMore} + totalLoaded={totalLoaded} + variant={variant} + /> + + {/* Legend */} + <Legend + edges={edges} + id={legendId} + isLoading={isLoading} + nodes={nodes} + variant={variant} + /> + + {/* Node detail panel */} + <AnimatePresence> + {selectedNodeData && ( + <NodeDetailPanel + node={selectedNodeData} + onClose={() => setSelectedNode(null)} + variant={variant} + /> + )} + </AnimatePresence> + + {/* Show welcome screen when no memories exist */} + {!isLoading && + (!data || nodes.filter((n) => n.type === "document").length === 0) && ( + <>{children}</> + )} + + {/* Graph container */} + <div + className={styles.graphContainer} + ref={containerRef} + > + {(containerSize.width > 0 && containerSize.height > 0) && ( + <GraphCanvas + draggingNodeId={draggingNodeId} + edges={edges} + height={containerSize.height} + nodes={nodes} + highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []} + onDoubleClick={handleDoubleClick} + onNodeClick={handleNodeClick} + onNodeDragEnd={handleNodeDragEnd} + onNodeDragMove={handleNodeDragMove} + onNodeDragStart={handleNodeDragStartWithNodes} + onNodeHover={handleNodeHover} + onPanEnd={handlePanEnd} + onPanMove={handlePanMove} + onPanStart={handlePanStart} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onWheel={handleWheel} + panX={panX} + panY={panY} + width={containerSize.width} + zoom={zoom} + /> + )} + + {/* Navigation controls */} + {containerSize.width > 0 && ( + <NavigationControls + onCenter={handleCenter} + onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)} + onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)} + onAutoFit={handleAutoFit} + nodes={nodes} + className={styles.navControlsContainer} + /> + )} + </div> + </div> + ); +}; diff --git a/packages/memory-graph/src/components/navigation-controls.css.ts b/packages/memory-graph/src/components/navigation-controls.css.ts new file mode 100644 index 00000000..3a4094bd --- /dev/null +++ b/packages/memory-graph/src/components/navigation-controls.css.ts @@ -0,0 +1,77 @@ +import { style } from "@vanilla-extract/css"; +import { themeContract } from "../styles/theme.css"; + +/** + * Navigation controls container + */ +export const navContainer = style({ + display: "flex", + flexDirection: "column", + gap: themeContract.space[1], +}); + +/** + * Base button styles for navigation controls + */ +const navButtonBase = style({ + backgroundColor: "rgba(0, 0, 0, 0.2)", + backdropFilter: "blur(8px)", + WebkitBackdropFilter: "blur(8px)", + border: `1px solid rgba(255, 255, 255, 0.1)`, + borderRadius: themeContract.radii.lg, + padding: themeContract.space[2], + color: "rgba(255, 255, 255, 0.7)", + fontSize: themeContract.typography.fontSize.xs, + fontWeight: themeContract.typography.fontWeight.medium, + minWidth: "64px", + cursor: "pointer", + transition: themeContract.transitions.normal, + + selectors: { + "&:hover": { + backgroundColor: "rgba(0, 0, 0, 0.3)", + borderColor: "rgba(255, 255, 255, 0.2)", + color: "rgba(255, 255, 255, 1)", + }, + }, +}); + +/** + * Standard navigation button + */ +export const navButton = navButtonBase; + +/** + * Zoom controls container + */ +export const zoomContainer = style({ + display: "flex", + flexDirection: "column", +}); + +/** + * Zoom in button (top rounded) + */ +export const zoomInButton = style([ + navButtonBase, + { + borderTopLeftRadius: themeContract.radii.lg, + borderTopRightRadius: themeContract.radii.lg, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + borderBottom: 0, + }, +]); + +/** + * Zoom out button (bottom rounded) + */ +export const zoomOutButton = style([ + navButtonBase, + { + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomLeftRadius: themeContract.radii.lg, + borderBottomRightRadius: themeContract.radii.lg, + }, +]); diff --git a/packages/memory-graph/src/components/navigation-controls.tsx b/packages/memory-graph/src/components/navigation-controls.tsx new file mode 100644 index 00000000..19caa888 --- /dev/null +++ b/packages/memory-graph/src/components/navigation-controls.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { memo } from "react"; +import type { GraphNode } from "@/types"; +import { + navContainer, + navButton, + zoomContainer, + zoomInButton, + zoomOutButton, +} from "./navigation-controls.css"; + +interface NavigationControlsProps { + onCenter: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onAutoFit: () => void; + nodes: GraphNode[]; + className?: string; +} + +export const NavigationControls = memo<NavigationControlsProps>( + ({ onCenter, onZoomIn, onZoomOut, onAutoFit, nodes, className = "" }) => { + if (nodes.length === 0) { + return null; + } + + const containerClassName = className + ? `${navContainer} ${className}` + : navContainer; + + return ( + <div className={containerClassName}> + <button + type="button" + onClick={onAutoFit} + className={navButton} + title="Auto-fit graph to viewport" + > + Fit + </button> + <button + type="button" + onClick={onCenter} + className={navButton} + title="Center view on graph" + > + Center + </button> + <div className={zoomContainer}> + <button + type="button" + onClick={onZoomIn} + className={zoomInButton} + title="Zoom in" + > + + + </button> + <button + type="button" + onClick={onZoomOut} + className={zoomOutButton} + title="Zoom out" + > + − + </button> + </div> + </div> + ); + }, +); + +NavigationControls.displayName = "NavigationControls"; diff --git a/packages/memory-graph/src/components/node-detail-panel.css.ts b/packages/memory-graph/src/components/node-detail-panel.css.ts new file mode 100644 index 00000000..a3c30e06 --- /dev/null +++ b/packages/memory-graph/src/components/node-detail-panel.css.ts @@ -0,0 +1,170 @@ +import { style } from "@vanilla-extract/css"; +import { themeContract } from "../styles/theme.css"; + +/** + * Main container (positioned absolutely) + * Highest z-index so it appears above everything when open + */ +export const container = style({ + position: "absolute", + width: "20rem", // w-80 = 320px = 20rem + borderRadius: themeContract.radii.xl, + overflow: "hidden", + zIndex: 40, // Highest priority - always on top when open + maxHeight: "calc(100vh - 2rem)", // Leave some breathing room + top: themeContract.space[4], + right: themeContract.space[4], + + // Add shadow for depth + boxShadow: "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)", +}); + +/** + * Content wrapper with scrolling + */ +export const content = style({ + position: "relative", + zIndex: 10, + padding: themeContract.space[4], + overflowY: "auto", + maxHeight: "80vh", +}); + +/** + * Header section + */ +export const header = style({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + marginBottom: themeContract.space[3], +}); + +export const headerLeft = style({ + display: "flex", + alignItems: "center", + gap: themeContract.space[2], +}); + +export const headerIcon = style({ + width: "1.25rem", + height: "1.25rem", + color: themeContract.colors.text.secondary, +}); + +export const headerIconMemory = style({ + width: "1.25rem", + height: "1.25rem", + color: "rgb(96, 165, 250)", // blue-400 +}); + +export const closeButton = style({ + height: "32px", + width: "32px", + padding: 0, + color: themeContract.colors.text.secondary, + + selectors: { + "&:hover": { + color: themeContract.colors.text.primary, + }, + }, +}); + +export const closeIcon = style({ + width: "1rem", + height: "1rem", +}); + +/** + * Content sections + */ +export const sections = style({ + display: "flex", + flexDirection: "column", + gap: themeContract.space[3], +}); + +export const section = style({}); + +export const sectionLabel = style({ + fontSize: themeContract.typography.fontSize.xs, + color: themeContract.colors.text.muted, + textTransform: "uppercase", + letterSpacing: "0.05em", +}); + +export const sectionValue = style({ + fontSize: themeContract.typography.fontSize.sm, + color: themeContract.colors.text.secondary, + marginTop: themeContract.space[1], +}); + +export const sectionValueTruncated = style({ + fontSize: themeContract.typography.fontSize.sm, + color: themeContract.colors.text.secondary, + marginTop: themeContract.space[1], + overflow: "hidden", + display: "-webkit-box", + WebkitLineClamp: 3, + WebkitBoxOrient: "vertical", +}); + +export const link = style({ + fontSize: themeContract.typography.fontSize.sm, + color: "rgb(129, 140, 248)", // indigo-400 + marginTop: themeContract.space[1], + display: "flex", + alignItems: "center", + gap: themeContract.space[1], + textDecoration: "none", + transition: themeContract.transitions.normal, + + selectors: { + "&:hover": { + color: "rgb(165, 180, 252)", // indigo-300 + }, + }, +}); + +export const linkIcon = style({ + width: "0.75rem", + height: "0.75rem", +}); + +export const badge = style({ + marginTop: themeContract.space[2], +}); + +export const expiryText = style({ + fontSize: themeContract.typography.fontSize.xs, + color: themeContract.colors.text.muted, + marginTop: themeContract.space[1], +}); + +/** + * Footer section (metadata) + */ +export const footer = style({ + paddingTop: themeContract.space[2], + borderTop: "1px solid rgba(71, 85, 105, 0.5)", // slate-700/50 +}); + +export const metadata = style({ + display: "flex", + alignItems: "center", + gap: themeContract.space[4], + fontSize: themeContract.typography.fontSize.xs, + color: themeContract.colors.text.muted, +}); + +export const metadataItem = style({ + display: "flex", + alignItems: "center", + gap: themeContract.space[1], +}); + +export const metadataIcon = style({ + width: "0.75rem", + height: "0.75rem", +}); diff --git a/packages/memory-graph/src/components/node-detail-panel.tsx b/packages/memory-graph/src/components/node-detail-panel.tsx new file mode 100644 index 00000000..e2ae0133 --- /dev/null +++ b/packages/memory-graph/src/components/node-detail-panel.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { Badge } from "@/ui/badge"; +import { Button } from "@/ui/button"; +import { GlassMenuEffect } from "@/ui/glass-effect"; +import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react"; +import { motion } from "motion/react"; +import { memo } from "react"; +import { + GoogleDocs, + GoogleDrive, + GoogleSheets, + GoogleSlides, + MicrosoftExcel, + MicrosoftOneNote, + MicrosoftPowerpoint, + MicrosoftWord, + NotionDoc, + OneDrive, + PDF, +} from "@/assets/icons"; +import { HeadingH3Bold } from "@/ui/heading"; +import type { + DocumentWithMemories, + MemoryEntry, +} from "@/types"; +import type { NodeDetailPanelProps } from "@/types"; +import * as styles from "./node-detail-panel.css"; + +const formatDocumentType = (type: string) => { + // Special case for PDF + if (type.toLowerCase() === "pdf") return "PDF"; + + // Replace underscores with spaces and capitalize each word + return type + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +}; + +const getDocumentIcon = (type: string) => { + const iconProps = { className: "w-5 h-5 text-slate-300" }; + + switch (type) { + case "google_doc": + return <GoogleDocs {...iconProps} />; + case "google_sheet": + return <GoogleSheets {...iconProps} />; + case "google_slide": + return <GoogleSlides {...iconProps} />; + case "google_drive": + return <GoogleDrive {...iconProps} />; + case "notion": + case "notion_doc": + return <NotionDoc {...iconProps} />; + case "word": + case "microsoft_word": + return <MicrosoftWord {...iconProps} />; + case "excel": + case "microsoft_excel": + return <MicrosoftExcel {...iconProps} />; + case "powerpoint": + case "microsoft_powerpoint": + return <MicrosoftPowerpoint {...iconProps} />; + case "onenote": + case "microsoft_onenote": + return <MicrosoftOneNote {...iconProps} />; + case "onedrive": + return <OneDrive {...iconProps} />; + case "pdf": + return <PDF {...iconProps} />; + default: + {/*@ts-ignore */} + return <FileText {...iconProps} />; + } +}; + +export const NodeDetailPanel = memo( + function NodeDetailPanel({ node, onClose, variant = "console" }: NodeDetailPanelProps) { + if (!node) return null; + + const isDocument = node.type === "document"; + const data = node.data; + + return ( + <motion.div + animate={{ opacity: 1 }} + className={styles.container} + exit={{ opacity: 0 }} + initial={{ opacity: 0 }} + transition={{ + duration: 0.2, + ease: "easeInOut", + }} + > + {/* Glass effect background */} + <GlassMenuEffect rounded="xl" /> + + <motion.div + animate={{ opacity: 1 }} + className={styles.content} + initial={{ opacity: 0 }} + transition={{ delay: 0.05, duration: 0.15 }} + > + <div className={styles.header}> + <div className={styles.headerLeft}> + {isDocument ? ( + getDocumentIcon((data as DocumentWithMemories).type ?? "") + ) : ( + // @ts-ignore + <Brain className={styles.headerIconMemory} /> + )} + <HeadingH3Bold> + {isDocument ? "Document" : "Memory"} + </HeadingH3Bold> + </div> + <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}> + <Button + className={styles.closeButton} + onClick={onClose} + size="sm" + variant="ghost" + > + {/* @ts-ignore */} + <X className={styles.closeIcon} /> + </Button> + </motion.div> + </div> + + <div className={styles.sections}> + {isDocument ? ( + <> + <div className={styles.section}> + <span className={styles.sectionLabel}> + Title + </span> + <p className={styles.sectionValue}> + {(data as DocumentWithMemories).title || + "Untitled Document"} + </p> + </div> + + {(data as DocumentWithMemories).summary && ( + <div className={styles.section}> + <span className={styles.sectionLabel}> + Summary + </span> + <p className={styles.sectionValueTruncated}> + {(data as DocumentWithMemories).summary} + </p> + </div> + )} + + <div className={styles.section}> + <span className={styles.sectionLabel}> + Type + </span> + <p className={styles.sectionValue}> + {formatDocumentType((data as DocumentWithMemories).type ?? "")} + </p> + </div> + + <div className={styles.section}> + <span className={styles.sectionLabel}> + Memory Count + </span> + <p className={styles.sectionValue}> + {(data as DocumentWithMemories).memoryEntries.length}{" "} + memories + </p> + </div> + + {((data as DocumentWithMemories).url || + (data as DocumentWithMemories).customId) && ( + <div className={styles.section}> + <span className={styles.sectionLabel}> + URL + </span> + <a + className={styles.link} + href={(() => { + const doc = data as DocumentWithMemories; + 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; + })()} + rel="noopener noreferrer" + target="_blank" + > + {/* @ts-ignore */} + <ExternalLink className={styles.linkIcon} /> + View Document + </a> + </div> + )} + </> + ) : ( + <> + <div className={styles.section}> + <span className={styles.sectionLabel}> + Memory + </span> + <p className={styles.sectionValue}> + {(data as MemoryEntry).memory} + </p> + {(data as MemoryEntry).isForgotten && ( + <Badge className={styles.badge} variant="destructive"> + Forgotten + </Badge> + )} + {(data as MemoryEntry).forgetAfter && ( + <p className={styles.expiryText}> + Expires:{" "} + {(data as MemoryEntry).forgetAfter + ? new Date( + (data as MemoryEntry).forgetAfter!, + ).toLocaleDateString() + : ""}{" "} + {("forgetReason" in data && + (data as any).forgetReason + ? `- ${(data as any).forgetReason}` + : null)} + </p> + )} + </div> + + <div className={styles.section}> + <span className={styles.sectionLabel}> + Space + </span> + <p className={styles.sectionValue}> + {(data as MemoryEntry).spaceId || "Default"} + </p> + </div> + </> + )} + + <div className={styles.footer}> + <div className={styles.metadata}> + <span className={styles.metadataItem}> + {/* @ts-ignore */} + <Calendar className={styles.metadataIcon} /> + {new Date(data.createdAt).toLocaleDateString()} + </span> + <span className={styles.metadataItem}> + {/* @ts-ignore */} + <Hash className={styles.metadataIcon} /> + {node.id} + </span> + </div> + </div> + </div> + </motion.div> + </motion.div> + ); + }, +); + +NodeDetailPanel.displayName = "NodeDetailPanel"; diff --git a/packages/memory-graph/src/components/spaces-dropdown.css.ts b/packages/memory-graph/src/components/spaces-dropdown.css.ts new file mode 100644 index 00000000..d7af2258 --- /dev/null +++ b/packages/memory-graph/src/components/spaces-dropdown.css.ts @@ -0,0 +1,158 @@ +import { style } from "@vanilla-extract/css"; +import { themeContract } from "../styles/theme.css"; + +/** + * Dropdown container + */ +export const container = style({ + position: "relative", +}); + +/** + * Main trigger button with gradient border effect + */ +export const trigger = style({ + display: "flex", + alignItems: "center", + gap: themeContract.space[3], + paddingLeft: themeContract.space[4], + paddingRight: themeContract.space[4], + paddingTop: themeContract.space[3], + paddingBottom: themeContract.space[3], + borderRadius: themeContract.radii.xl, + border: "2px solid transparent", + backgroundImage: + "linear-gradient(#1a1f29, #1a1f29), linear-gradient(150.262deg, #A4E8F5 0%, #267FFA 26%, #464646 49%, #747474 70%, #A4E8F5 100%)", + backgroundOrigin: "border-box", + backgroundClip: "padding-box, border-box", + boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.15)", + backdropFilter: "blur(12px)", + WebkitBackdropFilter: "blur(12px)", + transition: themeContract.transitions.normal, + cursor: "pointer", + minWidth: "15rem", // min-w-60 = 240px = 15rem + + selectors: { + "&:hover": { + boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.25)", + }, + }, +}); + +export const triggerIcon = style({ + width: "1rem", + height: "1rem", + color: themeContract.colors.text.secondary, +}); + +export const triggerContent = style({ + flex: 1, + textAlign: "left", +}); + +export const triggerLabel = style({ + fontSize: themeContract.typography.fontSize.sm, + color: themeContract.colors.text.secondary, + fontWeight: themeContract.typography.fontWeight.medium, +}); + +export const triggerSubtext = style({ + fontSize: themeContract.typography.fontSize.xs, + color: themeContract.colors.text.muted, +}); + +export const triggerChevron = style({ + width: "1rem", + height: "1rem", + color: themeContract.colors.text.secondary, + transition: "transform 200ms ease", +}); + +export const triggerChevronOpen = style({ + transform: "rotate(180deg)", +}); + +/** + * Dropdown menu + */ +export const dropdown = style({ + position: "absolute", + top: "100%", + left: 0, + right: 0, + marginTop: themeContract.space[2], + background: "rgba(15, 23, 42, 0.95)", // slate-900/95 + backdropFilter: "blur(12px)", + WebkitBackdropFilter: "blur(12px)", + border: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40 + borderRadius: themeContract.radii.xl, + boxShadow: + "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", // shadow-xl + zIndex: 20, + overflow: "hidden", +}); + +export const dropdownInner = style({ + padding: themeContract.space[1], +}); + +/** + * Dropdown items + */ +const dropdownItemBase = style({ + width: "100%", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingLeft: themeContract.space[3], + paddingRight: themeContract.space[3], + paddingTop: themeContract.space[2], + paddingBottom: themeContract.space[2], + borderRadius: themeContract.radii.lg, + textAlign: "left", + transition: themeContract.transitions.normal, + cursor: "pointer", + border: "none", + background: "transparent", +}); + +export const dropdownItem = style([ + dropdownItemBase, + { + color: themeContract.colors.text.secondary, + + selectors: { + "&:hover": { + backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50 + }, + }, + }, +]); + +export const dropdownItemActive = style([ + dropdownItemBase, + { + backgroundColor: "rgba(59, 130, 246, 0.2)", // blue-500/20 + color: "rgb(147, 197, 253)", // blue-300 + }, +]); + +export const dropdownItemLabel = style({ + fontSize: themeContract.typography.fontSize.sm, + flex: 1, +}); + +export const dropdownItemLabelTruncate = style({ + fontSize: themeContract.typography.fontSize.sm, + flex: 1, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +export const dropdownItemBadge = style({ + backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50 + color: themeContract.colors.text.secondary, + fontSize: themeContract.typography.fontSize.xs, + marginLeft: themeContract.space[2], +}); diff --git a/packages/memory-graph/src/components/spaces-dropdown.tsx b/packages/memory-graph/src/components/spaces-dropdown.tsx new file mode 100644 index 00000000..b70059f5 --- /dev/null +++ b/packages/memory-graph/src/components/spaces-dropdown.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Badge } from "@/ui/badge"; +import { ChevronDown, Eye } from "lucide-react"; +import { memo, useEffect, useRef, useState } from "react"; +import type { SpacesDropdownProps } from "@/types"; +import * as styles from "./spaces-dropdown.css"; + +export const SpacesDropdown = memo<SpacesDropdownProps>( + ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef<HTMLDivElement>(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const totalMemories = Object.values(spaceMemoryCounts).reduce( + (sum, count) => sum + count, + 0, + ); + + return ( + <div className={styles.container} ref={dropdownRef}> + <button + className={styles.trigger} + onClick={() => setIsOpen(!isOpen)} + type="button" + > + {/*@ts-ignore */} + <Eye className={styles.triggerIcon} /> + <div className={styles.triggerContent}> + <span className={styles.triggerLabel}> + {selectedSpace === "all" + ? "All Spaces" + : selectedSpace || "Select space"} + </span> + <div className={styles.triggerSubtext}> + {selectedSpace === "all" + ? `${totalMemories} total memories` + : `${spaceMemoryCounts[selectedSpace] || 0} memories`} + </div> + </div> + {/*@ts-ignore */} + <ChevronDown + className={`${styles.triggerChevron} ${isOpen ? styles.triggerChevronOpen : ""}`} + /> + </button> + + {isOpen && ( + <div className={styles.dropdown}> + <div className={styles.dropdownInner}> + <button + className={ + selectedSpace === "all" + ? styles.dropdownItemActive + : styles.dropdownItem + } + onClick={() => { + onSpaceChange("all"); + setIsOpen(false); + }} + type="button" + > + <span className={styles.dropdownItemLabel}>All Spaces</span> + <Badge className={styles.dropdownItemBadge}> + {totalMemories} + </Badge> + </button> + {availableSpaces.map((space) => ( + <button + className={ + selectedSpace === space + ? styles.dropdownItemActive + : styles.dropdownItem + } + key={space} + onClick={() => { + onSpaceChange(space); + setIsOpen(false); + }} + type="button" + > + <span className={styles.dropdownItemLabelTruncate}>{space}</span> + <Badge className={styles.dropdownItemBadge}> + {spaceMemoryCounts[space] || 0} + </Badge> + </button> + ))} + </div> + </div> + )} + </div> + ); + }, +); + +SpacesDropdown.displayName = "SpacesDropdown"; diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts new file mode 100644 index 00000000..23193601 --- /dev/null +++ b/packages/memory-graph/src/constants.ts @@ -0,0 +1,100 @@ +// Enhanced glass-morphism color palette +export const colors = { + background: { + primary: "#0f1419", // Deep dark blue-gray + secondary: "#1a1f29", // Slightly lighter + accent: "#252a35", // Card backgrounds + }, + document: { + primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white + secondary: "rgba(255, 255, 255, 0.12)", // More visible + accent: "rgba(255, 255, 255, 0.18)", // Hover state + border: "rgba(255, 255, 255, 0.25)", // Sharp borders + glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction + }, + memory: { + primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue + secondary: "rgba(147, 197, 253, 0.16)", // More visible + accent: "rgba(147, 197, 253, 0.24)", // Hover state + border: "rgba(147, 197, 253, 0.35)", // Sharp borders + glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction + }, + connection: { + weak: "rgba(148, 163, 184, 0)", // Very subtle + memory: "rgba(148, 163, 184, 0.3)", // Very subtle + medium: "rgba(148, 163, 184, 0.125)", // Medium visibility + strong: "rgba(148, 163, 184, 0.4)", // Strong connection + }, + text: { + primary: "#ffffff", // Pure white + secondary: "#e2e8f0", // Light gray + muted: "#94a3b8", // Medium gray + }, + accent: { + primary: "rgba(59, 130, 246, 0.7)", // Clean blue + secondary: "rgba(99, 102, 241, 0.6)", // Clean purple + glow: "rgba(147, 197, 253, 0.6)", // Subtle glow + amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring + emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new + }, + status: { + forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten + expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon + new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories + }, + relations: { + updates: "rgba(147, 77, 253, 0.5)", // purple + extends: "rgba(16, 185, 129, 0.5)", // green + derives: "rgba(147, 197, 253, 0.5)", // blue + }, +}; + +export const LAYOUT_CONSTANTS = { + centerX: 400, + centerY: 300, + clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble + spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out + documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out + minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius + memoryClusterRadius: 300, +}; + +// Graph view settings +export const GRAPH_SETTINGS = { + console: { + initialZoom: 0.8, // Higher zoom for console - better overview + initialPanX: 0, + initialPanY: 0, + }, + consumer: { + initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility + initialPanX: 400, // Pan towards center to compensate for larger layout + initialPanY: 300, // Pan towards center to compensate for larger layout + }, +}; + +// Responsive positioning for different app variants +export const POSITIONING = { + console: { + legend: { + desktop: "bottom-4 right-4", + mobile: "bottom-4 right-4", + }, + loadingIndicator: "top-20 right-4", + + spacesSelector: "top-4 left-4", + viewToggle: "", // Not used in console + nodeDetail: "top-4 right-4", + }, + consumer: { + legend: { + desktop: "top-18 right-4", + mobile: "bottom-[180px] left-4", + }, + loadingIndicator: "top-20 right-4", + + spacesSelector: "", // Hidden in consumer + viewToggle: "top-4 right-4", // Consumer has view toggle + nodeDetail: "top-4 right-4", + }, +}; diff --git a/packages/memory-graph/src/hooks/use-documents-query.ts b/packages/memory-graph/src/hooks/use-documents-query.ts new file mode 100644 index 00000000..eb9ab892 --- /dev/null +++ b/packages/memory-graph/src/hooks/use-documents-query.ts @@ -0,0 +1,113 @@ +"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/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts new file mode 100644 index 00000000..030eea61 --- /dev/null +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -0,0 +1,308 @@ +"use client"; + +import { + calculateSemanticSimilarity, + getConnectionVisualProps, + getMagicalConnectionColor, +} from "@/lib/similarity"; +import { useMemo } from "react"; +import { colors, LAYOUT_CONSTANTS } from "@/constants"; +import type { + DocumentsResponse, + DocumentWithMemories, + GraphEdge, + GraphNode, + MemoryEntry, + MemoryRelation, +} from "@/types"; + +export function useGraphData( + data: DocumentsResponse | null, + selectedSpace: string, + nodePositions: Map<string, { x: number; y: number }>, + draggingNodeId: string | null, +) { + return useMemo(() => { + if (!data?.documents) return { nodes: [], edges: [] }; + + const allNodes: GraphNode[] = []; + const allEdges: GraphEdge[] = []; + + // Filter documents that have memories in selected space + const filteredDocuments = data.documents + .map((doc) => ({ + ...doc, + memoryEntries: + selectedSpace === "all" + ? doc.memoryEntries + : doc.memoryEntries.filter( + (memory) => + (memory.spaceContainerTag ?? memory.spaceId ?? "default") === + selectedSpace, + ), + })) + .filter((doc) => doc.memoryEntries.length > 0); + + // Group documents by space for better clustering + const documentsBySpace = new Map<string, typeof filteredDocuments>(); + filteredDocuments.forEach((doc) => { + const docSpace = + doc.memoryEntries[0]?.spaceContainerTag ?? + doc.memoryEntries[0]?.spaceId ?? + "default"; + if (!documentsBySpace.has(docSpace)) { + documentsBySpace.set(docSpace, []); + } + const spaceDocsArr = documentsBySpace.get(docSpace); + if (spaceDocsArr) { + spaceDocsArr.push(doc); + } + }); + + // Enhanced Layout with Space Separation + const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } = + LAYOUT_CONSTANTS; + + /* 1. Build DOCUMENT nodes with space-aware clustering */ + const documentNodes: GraphNode[] = []; + let spaceIndex = 0; + + documentsBySpace.forEach((spaceDocs) => { + const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2; + const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing; + const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing; + const spaceCenterX = centerX + spaceOffsetX; + const spaceCenterY = centerY + spaceOffsetY; + + spaceDocs.forEach((doc, docIndex) => { + // Create proper circular layout with concentric rings + const docsPerRing = 6; // Start with 6 docs in inner ring + let currentRing = 0; + let docsInCurrentRing = docsPerRing; + let totalDocsInPreviousRings = 0; + + // Find which ring this document belongs to + while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) { + totalDocsInPreviousRings += docsInCurrentRing; + currentRing++; + docsInCurrentRing = docsPerRing + currentRing * 4; // Each ring has more docs + } + + // Position within the ring + const positionInRing = docIndex - totalDocsInPreviousRings; + const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2; + + // Radius increases significantly with each ring + const baseRadius = documentSpacing * 0.8; + const radius = + currentRing === 0 + ? baseRadius + : baseRadius + currentRing * documentSpacing * 1.2; + + const defaultX = spaceCenterX + Math.cos(angleInRing) * radius; + const defaultY = spaceCenterY + Math.sin(angleInRing) * radius; + + const customPos = nodePositions.get(doc.id); + + documentNodes.push({ + id: doc.id, + type: "document", + x: customPos?.x ?? defaultX, + y: customPos?.y ?? defaultY, + data: doc, + size: 58, + color: colors.document.primary, + isHovered: false, + isDragging: draggingNodeId === doc.id, + } satisfies GraphNode); + }); + + spaceIndex++; + }); + + /* 2. Gentle document collision avoidance with dampening */ + const minDocDist = LAYOUT_CONSTANTS.minDocDist; + + // Reduced iterations and gentler repulsion for smoother movement + for (let iter = 0; iter < 2; iter++) { + documentNodes.forEach((nodeA) => { + documentNodes.forEach((nodeB) => { + if (nodeA.id >= nodeB.id) return; + + // Only repel documents in the same space + const spaceA = + (nodeA.data as DocumentWithMemories).memoryEntries[0] + ?.spaceContainerTag ?? + (nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? + "default"; + const spaceB = + (nodeB.data as DocumentWithMemories).memoryEntries[0] + ?.spaceContainerTag ?? + (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? + "default"; + + if (spaceA !== spaceB) return; + + const dx = nodeB.x - nodeA.x; + const dy = nodeB.y - nodeA.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + + if (dist < minDocDist) { + // Much gentler push with dampening + const push = (minDocDist - dist) / 8; + const dampening = Math.max(0.1, Math.min(1, dist / minDocDist)); + const smoothPush = push * dampening * 0.5; + + const nx = dx / dist; + const ny = dy / dist; + nodeA.x -= nx * smoothPush; + nodeA.y -= ny * smoothPush; + nodeB.x += nx * smoothPush; + nodeB.y += ny * smoothPush; + } + }); + }); + } + + allNodes.push(...documentNodes); + + /* 3. Add memories around documents WITH doc-memory connections */ + documentNodes.forEach((docNode) => { + const memoryNodeMap = new Map<string, GraphNode>(); + const doc = docNode.data as DocumentWithMemories; + + doc.memoryEntries.forEach((memory, memIndex) => { + const memoryId = `${memory.id}`; + const customMemPos = nodePositions.get(memoryId); + + const clusterAngle = + (memIndex / doc.memoryEntries.length) * Math.PI * 2; + const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7; + const distance = clusterRadius * variation; + + const seed = + memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36); + const offsetX = Math.sin(seed) * 0.5 * 40; + const offsetY = Math.cos(seed) * 0.5 * 40; + + const defaultMemX = + docNode.x + Math.cos(clusterAngle) * distance + offsetX; + const defaultMemY = + docNode.y + Math.sin(clusterAngle) * distance + offsetY; + + if (!memoryNodeMap.has(memoryId)) { + const memoryNode: GraphNode = { + id: memoryId, + type: "memory", + x: customMemPos?.x ?? defaultMemX, + y: customMemPos?.y ?? defaultMemY, + data: memory, + size: Math.max( + 32, + Math.min(48, (memory.memory?.length || 50) * 0.5), + ), + color: colors.memory.primary, + isHovered: false, + isDragging: draggingNodeId === memoryId, + }; + memoryNodeMap.set(memoryId, memoryNode); + allNodes.push(memoryNode); + } + + // Create doc-memory edge with similarity + allEdges.push({ + id: `edge-${docNode.id}-${memory.id}`, + source: docNode.id, + target: memoryId, + similarity: 1, + visualProps: getConnectionVisualProps(1), + color: colors.connection.memory, + edgeType: "doc-memory", + }); + }); + }); + + // Build mapping of memoryId -> nodeId for version chains + const memNodeIdMap = new Map<string, string>(); + allNodes.forEach((n) => { + if (n.type === "memory") { + memNodeIdMap.set((n.data as MemoryEntry).id, n.id); + } + }); + + // Add version-chain edges (old -> new) + data.documents.forEach((doc) => { + doc.memoryEntries.forEach((mem: MemoryEntry) => { + // Support both new object structure and legacy array/single parent fields + let parentRelations: Record<string, MemoryRelation> = {}; + + if ( + mem.memoryRelations && + Array.isArray(mem.memoryRelations) && + mem.memoryRelations.length > 0 + ) { + // Convert array to Record + parentRelations = mem.memoryRelations.reduce((acc, rel) => { + acc[rel.targetMemoryId] = rel.relationType; + return acc; + }, {} as Record<string, MemoryRelation>); + } else if (mem.parentMemoryId) { + parentRelations = { + [mem.parentMemoryId]: "updates" as MemoryRelation, + }; + } + Object.entries(parentRelations).forEach(([pid, relationType]) => { + const fromId = memNodeIdMap.get(pid); + const toId = memNodeIdMap.get(mem.id); + if (fromId && toId) { + allEdges.push({ + id: `version-${fromId}-${toId}`, + source: fromId, + target: toId, + similarity: 1, + visualProps: { + opacity: 0.8, + thickness: 1, + glow: 0, + pulseDuration: 3000, + }, + // choose color based on relation type + color: colors.relations[relationType] ?? colors.relations.updates, + edgeType: "version", + relationType: relationType as MemoryRelation, + }); + } + }); + }); + }); + + // Document-to-document similarity edges + for (let i = 0; i < filteredDocuments.length; i++) { + const docI = filteredDocuments[i]; + if (!docI) continue; + + for (let j = i + 1; j < filteredDocuments.length; j++) { + const docJ = filteredDocuments[j]; + if (!docJ) continue; + + const sim = calculateSemanticSimilarity( + docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null, + docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null, + ); + if (sim > 0.725) { + allEdges.push({ + id: `doc-doc-${docI.id}-${docJ.id}`, + source: docI.id, + target: docJ.id, + similarity: sim, + visualProps: getConnectionVisualProps(sim), + color: getMagicalConnectionColor(sim, 200), + edgeType: "doc-doc", + }); + } + } + } + + return { nodes: allNodes, edges: allEdges }; + }, [data, selectedSpace, nodePositions, draggingNodeId]); +} diff --git a/packages/memory-graph/src/hooks/use-graph-interactions.ts b/packages/memory-graph/src/hooks/use-graph-interactions.ts new file mode 100644 index 00000000..fa794397 --- /dev/null +++ b/packages/memory-graph/src/hooks/use-graph-interactions.ts @@ -0,0 +1,564 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { GRAPH_SETTINGS } from "@/constants"; +import type { GraphNode } from "@/types"; + +export function useGraphInteractions( + variant: "console" | "consumer" = "console", +) { + const settings = GRAPH_SETTINGS[variant]; + + const [panX, setPanX] = useState(settings.initialPanX); + const [panY, setPanY] = useState(settings.initialPanY); + const [zoom, setZoom] = useState(settings.initialZoom); + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [hoveredNode, setHoveredNode] = useState<string | null>(null); + const [selectedNode, setSelectedNode] = useState<string | null>(null); + const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null); + const [dragStart, setDragStart] = useState({ + x: 0, + y: 0, + nodeX: 0, + nodeY: 0, + }); + const [nodePositions, setNodePositions] = useState< + Map<string, { x: number; y: number }> + >(new Map()); + + // Touch gesture state + const [touchState, setTouchState] = useState<{ + touches: { id: number; x: number; y: number }[]; + lastDistance: number; + lastCenter: { x: number; y: number }; + isGesturing: boolean; + }>({ + touches: [], + lastDistance: 0, + lastCenter: { x: 0, y: 0 }, + isGesturing: false, + }); + + // Animation state for smooth transitions + const animationRef = useRef<number | null>(null); + const [isAnimating, setIsAnimating] = useState(false); + + // Smooth animation helper + const animateToViewState = useCallback( + ( + targetPanX: number, + targetPanY: number, + targetZoom: number, + duration = 300, + ) => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + + const startPanX = panX; + const startPanY = panY; + const startZoom = zoom; + const startTime = Date.now(); + + setIsAnimating(true); + + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + + // Ease out cubic function for smooth transitions + const easeOut = 1 - (1 - progress) ** 3; + + const currentPanX = startPanX + (targetPanX - startPanX) * easeOut; + const currentPanY = startPanY + (targetPanY - startPanY) * easeOut; + const currentZoom = startZoom + (targetZoom - startZoom) * easeOut; + + setPanX(currentPanX); + setPanY(currentPanY); + setZoom(currentZoom); + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate); + } else { + setIsAnimating(false); + animationRef.current = null; + } + }; + + animate(); + }, + [panX, panY, zoom], + ); + + // Node drag handlers + const handleNodeDragStart = useCallback( + (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => { + const node = nodes?.find((n) => n.id === nodeId); + if (!node) return; + + setDraggingNodeId(nodeId); + setDragStart({ + x: e.clientX, + y: e.clientY, + nodeX: node.x, + nodeY: node.y, + }); + }, + [], + ); + + const handleNodeDragMove = useCallback( + (e: React.MouseEvent) => { + if (!draggingNodeId) return; + + const deltaX = (e.clientX - dragStart.x) / zoom; + const deltaY = (e.clientY - dragStart.y) / zoom; + + const newX = dragStart.nodeX + deltaX; + const newY = dragStart.nodeY + deltaY; + + setNodePositions((prev) => + new Map(prev).set(draggingNodeId, { x: newX, y: newY }), + ); + }, + [draggingNodeId, dragStart, zoom], + ); + + const handleNodeDragEnd = useCallback(() => { + setDraggingNodeId(null); + }, []); + + // Pan handlers + const handlePanStart = useCallback( + (e: React.MouseEvent) => { + setIsPanning(true); + setPanStart({ x: e.clientX - panX, y: e.clientY - panY }); + }, + [panX, panY], + ); + + const handlePanMove = useCallback( + (e: React.MouseEvent) => { + if (!isPanning || draggingNodeId) return; + + const newPanX = e.clientX - panStart.x; + const newPanY = e.clientY - panStart.y; + setPanX(newPanX); + setPanY(newPanY); + }, + [isPanning, panStart, draggingNodeId], + ); + + const handlePanEnd = useCallback(() => { + setIsPanning(false); + }, []); + + // Zoom handlers + const handleWheel = useCallback( + (e: React.WheelEvent) => { + // Always prevent default to stop browser navigation + e.preventDefault(); + e.stopPropagation(); + + // Handle horizontal scrolling (trackpad swipe) by converting to pan + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + // Horizontal scroll - pan the graph instead of zooming + const panDelta = e.deltaX * 0.5; + setPanX((prev) => prev - panDelta); + return; + } + + // Vertical scroll - zoom behavior + const delta = e.deltaY > 0 ? 0.97 : 1.03; + const newZoom = Math.max(0.05, Math.min(3, zoom * delta)); + + // Get mouse position relative to the viewport + let mouseX = e.clientX; + let mouseY = e.clientY; + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget; + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + mouseY = e.clientY - rect.top; + } + + // Calculate the world position of the mouse cursor + const worldX = (mouseX - panX) / zoom; + const worldY = (mouseY - panY) / zoom; + + // Calculate new pan to keep the mouse position stationary + const newPanX = mouseX - worldX * newZoom; + const newPanY = mouseY - worldY * newZoom; + + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + }, + [zoom, panX, panY], + ); + + const zoomIn = useCallback( + (centerX?: number, centerY?: number, animate = true) => { + const zoomFactor = 1.2; + const newZoom = Math.min(3, zoom * zoomFactor); // Increased max zoom to 3x + + if (centerX !== undefined && centerY !== undefined) { + // Mouse-centered zoom for programmatic zoom in + const worldX = (centerX - panX) / zoom; + const worldY = (centerY - panY) / zoom; + const newPanX = centerX - worldX * newZoom; + const newPanY = centerY - worldY * newZoom; + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, newZoom, 200); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } + } else { + if (animate && !isAnimating) { + animateToViewState(panX, panY, newZoom, 200); + } else { + setZoom(newZoom); + } + } + }, + [zoom, panX, panY, isAnimating, animateToViewState], + ); + + const zoomOut = useCallback( + (centerX?: number, centerY?: number, animate = true) => { + const zoomFactor = 0.8; + const newZoom = Math.max(0.05, zoom * zoomFactor); // Decreased min zoom to 0.05x + + if (centerX !== undefined && centerY !== undefined) { + // Mouse-centered zoom for programmatic zoom out + const worldX = (centerX - panX) / zoom; + const worldY = (centerY - panY) / zoom; + const newPanX = centerX - worldX * newZoom; + const newPanY = centerY - worldY * newZoom; + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, newZoom, 200); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } + } else { + if (animate && !isAnimating) { + animateToViewState(panX, panY, newZoom, 200); + } else { + setZoom(newZoom); + } + } + }, + [zoom, panX, panY, isAnimating, animateToViewState], + ); + + const resetView = useCallback(() => { + setPanX(settings.initialPanX); + setPanY(settings.initialPanY); + setZoom(settings.initialZoom); + setNodePositions(new Map()); + }, [settings]); + + // Auto-fit graph to viewport + const autoFitToViewport = useCallback( + ( + nodes: GraphNode[], + viewportWidth: number, + viewportHeight: number, + options?: { occludedRightPx?: number; animate?: boolean }, + ) => { + if (nodes.length === 0) return; + + // Find the bounds of all nodes + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + nodes.forEach((node) => { + minX = Math.min(minX, node.x - node.size / 2); + maxX = Math.max(maxX, node.x + node.size / 2); + minY = Math.min(minY, node.y - node.size / 2); + maxY = Math.max(maxY, node.y + node.size / 2); + }); + + // Calculate the center of the content + const contentCenterX = (minX + maxX) / 2; + const contentCenterY = (minY + maxY) / 2; + + // Calculate the size of the content + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + + // Add padding (20% on each side) + const paddingFactor = 1.4; + const paddedWidth = contentWidth * paddingFactor; + const paddedHeight = contentHeight * paddingFactor; + + // Account for occluded area on the right (e.g., chat panel) + const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0); + const availableWidth = Math.max(1, viewportWidth - occludedRightPx); + + // Calculate the zoom needed to fit the content within available width + const zoomX = availableWidth / paddedWidth; + const zoomY = viewportHeight / paddedHeight; + const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3); + + // Calculate pan to center the content within available area + const availableCenterX = availableWidth / 2; + const newPanX = availableCenterX - contentCenterX * newZoom; + const newPanY = viewportHeight / 2 - contentCenterY * newZoom; + + // Apply the new view (optional animation) + if (options?.animate) { + const steps = 8; + const durationMs = 160; // snappy + const intervalMs = Math.max(1, Math.floor(durationMs / steps)); + const startZoom = zoom; + const startPanX = panX; + const startPanY = panY; + let i = 0; + const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad + const timer = setInterval(() => { + i++; + const t = ease(i / steps); + setZoom(startZoom + (newZoom - startZoom) * t); + setPanX(startPanX + (newPanX - startPanX) * t); + setPanY(startPanY + (newPanY - startPanY) * t); + if (i >= steps) clearInterval(timer); + }, intervalMs); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } + }, + [zoom, panX, panY], + ); + + // Touch gesture handlers for mobile pinch-to-zoom + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })); + + if (touches.length >= 2) { + // Start gesture with two or more fingers + const touch1 = touches[0]!; + const touch2 = touches[1]!; + + const distance = Math.sqrt( + (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, + ); + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + }; + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }); + } else { + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })); + } + }, []); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); + + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })); + + if (touches.length >= 2 && touchState.isGesturing) { + const touch1 = touches[0]!; + const touch2 = touches[1]!; + + const distance = Math.sqrt( + (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, + ); + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + }; + + // Calculate zoom change based on pinch distance change + const distanceChange = distance / touchState.lastDistance; + const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange)); + + // Get canvas bounds for center calculation + const canvas = e.currentTarget as HTMLElement; + const rect = canvas.getBoundingClientRect(); + const centerX = center.x - rect.left; + const centerY = center.y - rect.top; + + // Calculate the world position of the pinch center + const worldX = (centerX - panX) / zoom; + const worldY = (centerY - panY) / zoom; + + // Calculate new pan to keep the pinch center stationary + const newPanX = centerX - worldX * newZoom; + const newPanY = centerY - worldY * newZoom; + + // Calculate pan change based on center movement + const centerDx = center.x - touchState.lastCenter.x; + const centerDy = center.y - touchState.lastCenter.y; + + setZoom(newZoom); + setPanX(newPanX + centerDx); + setPanY(newPanY + centerDy); + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }); + } else if (touches.length === 1 && !touchState.isGesturing && isPanning) { + // Single finger pan (only if not in gesture mode) + const touch = touches[0]!; + const newPanX = touch.x - panStart.x; + const newPanY = touch.y - panStart.y; + setPanX(newPanX); + setPanY(newPanY); + } + }, + [touchState, zoom, panX, panY, isPanning, panStart], + ); + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })); + + if (touches.length < 2) { + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })); + } else { + setTouchState((prev) => ({ ...prev, touches })); + } + + if (touches.length === 0) { + setIsPanning(false); + } + }, []); + + // Center viewport on a specific world position (with animation) + const centerViewportOn = useCallback( + ( + worldX: number, + worldY: number, + viewportWidth: number, + viewportHeight: number, + animate = true, + ) => { + const newPanX = viewportWidth / 2 - worldX * zoom; + const newPanY = viewportHeight / 2 - worldY * zoom; + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, zoom, 400); + } else { + setPanX(newPanX); + setPanY(newPanY); + } + }, + [zoom, isAnimating, animateToViewState], + ); + + // Node interaction handlers + const handleNodeHover = useCallback((nodeId: string | null) => { + setHoveredNode(nodeId); + }, []); + + const handleNodeClick = useCallback( + (nodeId: string) => { + setSelectedNode(selectedNode === nodeId ? null : nodeId); + }, + [selectedNode], + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + // Calculate new zoom (zoom in by 1.5x) + const zoomFactor = 1.5; + const newZoom = Math.min(3, zoom * zoomFactor); + + // Get mouse position relative to the container + let mouseX = e.clientX; + let mouseY = e.clientY; + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget; + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + mouseY = e.clientY - rect.top; + } + + // Calculate the world position of the clicked point + const worldX = (mouseX - panX) / zoom; + const worldY = (mouseY - panY) / zoom; + + // Calculate new pan to keep the clicked point in the same screen position + const newPanX = mouseX - worldX * newZoom; + const newPanY = mouseY - worldY * newZoom; + + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + }, + [zoom, panX, panY], + ); + + return { + // State + panX, + panY, + zoom, + hoveredNode, + selectedNode, + draggingNodeId, + nodePositions, + // Handlers + handlePanStart, + handlePanMove, + handlePanEnd, + handleWheel, + handleNodeHover, + handleNodeClick, + handleNodeDragStart, + handleNodeDragMove, + handleNodeDragEnd, + handleDoubleClick, + // Touch handlers + handleTouchStart, + handleTouchMove, + handleTouchEnd, + // Controls + zoomIn, + zoomOut, + resetView, + autoFitToViewport, + centerViewportOn, + setSelectedNode, + }; +} diff --git a/packages/memory-graph/src/hooks/use-mobile.ts b/packages/memory-graph/src/hooks/use-mobile.ts new file mode 100644 index 00000000..283bbb4c --- /dev/null +++ b/packages/memory-graph/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx new file mode 100644 index 00000000..1e413e00 --- /dev/null +++ b/packages/memory-graph/src/index.tsx @@ -0,0 +1,46 @@ +// Auto-inject global styles (side effect import) +import "./styles"; + +// Export the main component +export { MemoryGraphWrapper as MemoryGraph } from "./components/memory-graph-wrapper"; + +// Export types for consumers +export type { + MemoryGraphWrapperProps as MemoryGraphProps, +} from "./components/memory-graph-wrapper"; + +export type { + DocumentWithMemories, + MemoryEntry, + DocumentsResponse, +} from "./api-types"; + +export type { + GraphNode, + GraphEdge, + 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"; +export type { 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 new file mode 100644 index 00000000..faef4d06 --- /dev/null +++ b/packages/memory-graph/src/lib/api-client.ts @@ -0,0 +1,213 @@ +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/similarity.ts b/packages/memory-graph/src/lib/similarity.ts new file mode 100644 index 00000000..09d3a2cc --- /dev/null +++ b/packages/memory-graph/src/lib/similarity.ts @@ -0,0 +1,115 @@ +// Utility functions for calculating semantic similarity between documents and memories + +/** + * Calculate cosine similarity between two normalized vectors (unit vectors) + * Since all embeddings in this system are normalized using normalizeEmbeddingFast, + * cosine similarity equals dot product for unit vectors. + */ +export const cosineSimilarity = ( + vectorA: number[], + vectorB: number[], +): number => { + if (vectorA.length !== vectorB.length) { + throw new Error("Vectors must have the same length") + } + + let dotProduct = 0 + + for (let i = 0; i < vectorA.length; i++) { + const vectorAi = vectorA[i] + const vectorBi = vectorB[i] + if ( + typeof vectorAi !== "number" || + typeof vectorBi !== "number" || + isNaN(vectorAi) || + isNaN(vectorBi) + ) { + throw new Error("Vectors must contain only numbers") + } + dotProduct += vectorAi * vectorBi + } + + return dotProduct +} + +/** + * Calculate semantic similarity between two documents + * Returns a value between 0 and 1, where 1 is most similar + */ +export const calculateSemanticSimilarity = ( + document1Embedding: number[] | null, + document2Embedding: number[] | null, +): number => { + // If we have both embeddings, use cosine similarity + if ( + document1Embedding && + document2Embedding && + document1Embedding.length > 0 && + document2Embedding.length > 0 + ) { + const similarity = cosineSimilarity(document1Embedding, document2Embedding) + // Convert from [-1, 1] to [0, 1] range + return similarity >= 0 ? similarity : 0 + } + + return 0 +} + +/** + * Calculate semantic similarity between a document and memory entry + * Returns a value between 0 and 1, where 1 is most similar + */ +export const calculateDocumentMemorySimilarity = ( + documentEmbedding: number[] | null, + memoryEmbedding: number[] | null, + relevanceScore?: number | null, +): number => { + // If we have both embeddings, use cosine similarity + if ( + documentEmbedding && + memoryEmbedding && + documentEmbedding.length > 0 && + memoryEmbedding.length > 0 + ) { + const similarity = cosineSimilarity(documentEmbedding, memoryEmbedding) + // Convert from [-1, 1] to [0, 1] range + return similarity >= 0 ? similarity : 0 + } + + // Fall back to relevance score from database (0-100 scale) + if (relevanceScore !== null && relevanceScore !== undefined) { + return Math.max(0, Math.min(1, relevanceScore / 100)) + } + + // Default similarity for connections without embeddings or relevance scores + return 0.5 +} + +/** + * Get visual properties for connection based on similarity + */ +export const getConnectionVisualProps = (similarity: number) => { + // Ensure similarity is between 0 and 1 + const normalizedSimilarity = Math.max(0, Math.min(1, similarity)) + + return { + opacity: Math.max(0, normalizedSimilarity), // 0 to 1 range + thickness: Math.max(1, normalizedSimilarity * 4), // 1 to 4 pixels + glow: normalizedSimilarity * 0.6, // Glow intensity + pulseDuration: 2000 + (1 - normalizedSimilarity) * 3000, // Faster pulse for higher similarity + } +} + +/** + * Generate magical color based on similarity and connection type + */ +export const getMagicalConnectionColor = ( + similarity: number, + hue = 220, +): string => { + const normalizedSimilarity = Math.max(0, Math.min(1, similarity)) + const saturation = 60 + normalizedSimilarity * 40 // 60% to 100% + const lightness = 40 + normalizedSimilarity * 30 // 40% to 70% + + return `hsl(${hue}, ${saturation}%, ${lightness}%)` +} diff --git a/packages/memory-graph/src/styles/animations.css.ts b/packages/memory-graph/src/styles/animations.css.ts new file mode 100644 index 00000000..d9430ec4 --- /dev/null +++ b/packages/memory-graph/src/styles/animations.css.ts @@ -0,0 +1,116 @@ +import { keyframes } from "@vanilla-extract/css"; + +/** + * Animation keyframes + * Used throughout the component library for consistent motion + */ + +export const fadeIn = keyframes({ + from: { opacity: 0 }, + to: { opacity: 1 }, +}); + +export const fadeOut = keyframes({ + from: { opacity: 1 }, + to: { opacity: 0 }, +}); + +export const slideInFromRight = keyframes({ + from: { + transform: "translateX(100%)", + opacity: 0, + }, + to: { + transform: "translateX(0)", + opacity: 1, + }, +}); + +export const slideInFromLeft = keyframes({ + from: { + transform: "translateX(-100%)", + opacity: 0, + }, + to: { + transform: "translateX(0)", + opacity: 1, + }, +}); + +export const slideInFromTop = keyframes({ + from: { + transform: "translateY(-100%)", + opacity: 0, + }, + to: { + transform: "translateY(0)", + opacity: 1, + }, +}); + +export const slideInFromBottom = keyframes({ + from: { + transform: "translateY(100%)", + opacity: 0, + }, + to: { + transform: "translateY(0)", + opacity: 1, + }, +}); + +export const spin = keyframes({ + from: { transform: "rotate(0deg)" }, + to: { transform: "rotate(360deg)" }, +}); + +export const pulse = keyframes({ + "0%, 100%": { + opacity: 1, + }, + "50%": { + opacity: 0.5, + }, +}); + +export const bounce = keyframes({ + "0%, 100%": { + transform: "translateY(-25%)", + animationTimingFunction: "cubic-bezier(0.8, 0, 1, 1)", + }, + "50%": { + transform: "translateY(0)", + animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)", + }, +}); + +export const scaleIn = keyframes({ + from: { + transform: "scale(0.95)", + opacity: 0, + }, + to: { + transform: "scale(1)", + opacity: 1, + }, +}); + +export const scaleOut = keyframes({ + from: { + transform: "scale(1)", + opacity: 1, + }, + to: { + transform: "scale(0.95)", + opacity: 0, + }, +}); + +export const shimmer = keyframes({ + "0%": { + backgroundPosition: "-1000px 0", + }, + "100%": { + backgroundPosition: "1000px 0", + }, +}); diff --git a/packages/memory-graph/src/styles/effects.css.ts b/packages/memory-graph/src/styles/effects.css.ts new file mode 100644 index 00000000..2a290d32 --- /dev/null +++ b/packages/memory-graph/src/styles/effects.css.ts @@ -0,0 +1,120 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { themeContract } from "./theme.css"; + +/** + * Base glass-morphism effect + * Provides the signature frosted glass look + */ +const glassBase = style({ + backdropFilter: "blur(12px)", + WebkitBackdropFilter: "blur(12px)", + border: `1px solid ${themeContract.colors.document.border}`, + borderRadius: themeContract.radii.lg, +}); + +/** + * Glass effect variants + */ +export const glass = styleVariants({ + /** + * Light glass effect - subtle background + */ + light: [ + glassBase, + { + background: "rgba(255, 255, 255, 0.05)", + }, + ], + + /** + * Medium glass effect - more visible + */ + medium: [ + glassBase, + { + background: "rgba(255, 255, 255, 0.08)", + }, + ], + + /** + * Dark glass effect - prominent + */ + dark: [ + glassBase, + { + background: "rgba(15, 20, 25, 0.8)", + backdropFilter: "blur(20px)", + WebkitBackdropFilter: "blur(20px)", + }, + ], +}); + +/** + * Glass panel styles for larger containers + */ +export const glassPanel = styleVariants({ + default: { + background: "rgba(15, 20, 25, 0.8)", + backdropFilter: "blur(20px)", + WebkitBackdropFilter: "blur(20px)", + border: `1px solid ${themeContract.colors.document.border}`, + borderRadius: themeContract.radii.xl, + }, + bordered: { + background: "rgba(15, 20, 25, 0.8)", + backdropFilter: "blur(20px)", + WebkitBackdropFilter: "blur(20px)", + border: `2px solid ${themeContract.colors.document.border}`, + borderRadius: themeContract.radii.xl, + }, +}); + +/** + * Focus ring styles for accessibility + */ +export const focusRing = style({ + outline: "none", + selectors: { + "&:focus-visible": { + outline: `2px solid ${themeContract.colors.accent.primary}`, + outlineOffset: "2px", + }, + }, +}); + +/** + * Transition presets + */ +export const transition = styleVariants({ + fast: { + transition: themeContract.transitions.fast, + }, + normal: { + transition: themeContract.transitions.normal, + }, + slow: { + transition: themeContract.transitions.slow, + }, + all: { + transition: `all ${themeContract.transitions.normal}`, + }, + colors: { + transition: `background-color ${themeContract.transitions.normal}, color ${themeContract.transitions.normal}, border-color ${themeContract.transitions.normal}`, + }, + transform: { + transition: `transform ${themeContract.transitions.normal}`, + }, +}); + +/** + * Hover glow effect + */ +export const hoverGlow = style({ + position: "relative", + transition: themeContract.transitions.normal, + selectors: { + "&:hover": { + boxShadow: `0 0 20px ${themeContract.colors.document.glow}`, + }, + }, +}); diff --git a/packages/memory-graph/src/styles/global.css.ts b/packages/memory-graph/src/styles/global.css.ts new file mode 100644 index 00000000..cbe37913 --- /dev/null +++ b/packages/memory-graph/src/styles/global.css.ts @@ -0,0 +1,71 @@ +import { globalStyle } from "@vanilla-extract/css"; + +/** + * Global CSS reset and base styles + */ + +// Box sizing reset +globalStyle("*, *::before, *::after", { + boxSizing: "border-box", +}); + +// Remove default margins +globalStyle("body, h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd", { + margin: 0, +}); + +// Remove list styles +globalStyle("ul[role='list'], ol[role='list']", { + listStyle: "none", +}); + +// Core body defaults +globalStyle("html, body", { + height: "100%", +}); + +globalStyle("body", { + lineHeight: 1.5, + WebkitFontSmoothing: "antialiased", + MozOsxFontSmoothing: "grayscale", +}); + +// Typography defaults +globalStyle("h1, h2, h3, h4, h5, h6", { + fontWeight: 500, + lineHeight: 1.25, +}); + +// Inherit fonts for inputs and buttons +globalStyle("input, button, textarea, select", { + font: "inherit", +}); + +// Remove default button styles +globalStyle("button", { + background: "none", + border: "none", + padding: 0, + cursor: "pointer", +}); + +// Improve media defaults +globalStyle("img, picture, video, canvas, svg", { + display: "block", + maxWidth: "100%", +}); + +// Remove built-in form typography styles +globalStyle("input, button, textarea, select", { + font: "inherit", +}); + +// Avoid text overflows +globalStyle("p, h1, h2, h3, h4, h5, h6", { + overflowWrap: "break-word", +}); + +// Improve text rendering +globalStyle("#root, #__next", { + isolation: "isolate", +}); diff --git a/packages/memory-graph/src/styles/index.ts b/packages/memory-graph/src/styles/index.ts new file mode 100644 index 00000000..f619c689 --- /dev/null +++ b/packages/memory-graph/src/styles/index.ts @@ -0,0 +1,20 @@ +/** + * Style system exports + * Provides theme, sprinkles, animations, and effects for the memory-graph package + */ + +// Import global styles (side effect) +import "./global.css"; + +// Theme +export { themeContract, defaultTheme } from "./theme.css"; + +// Sprinkles utilities +export { sprinkles } from "./sprinkles.css"; +export type { Sprinkles } from "./sprinkles.css"; + +// Animations +export * as animations from "./animations.css"; + +// Glass-morphism effects +export { glass, glassPanel, focusRing, transition, hoverGlow } from "./effects.css"; diff --git a/packages/memory-graph/src/styles/sprinkles.css.ts b/packages/memory-graph/src/styles/sprinkles.css.ts new file mode 100644 index 00000000..ecd7a024 --- /dev/null +++ b/packages/memory-graph/src/styles/sprinkles.css.ts @@ -0,0 +1,204 @@ +import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles"; +import { themeContract } from "./theme.css"; + +/** + * Responsive conditions for mobile-first design + */ +const responsiveProperties = defineProperties({ + conditions: { + mobile: {}, + tablet: { "@media": "screen and (min-width: 768px)" }, + desktop: { "@media": "screen and (min-width: 1024px)" }, + }, + defaultCondition: "mobile", + properties: { + // Display + display: ["none", "flex", "block", "inline", "inline-flex", "grid"], + + // Flexbox + flexDirection: ["row", "column", "row-reverse", "column-reverse"], + justifyContent: [ + "stretch", + "flex-start", + "center", + "flex-end", + "space-between", + "space-around", + "space-evenly", + ], + alignItems: ["stretch", "flex-start", "center", "flex-end", "baseline"], + flexWrap: ["nowrap", "wrap", "wrap-reverse"], + gap: themeContract.space, + + // Spacing + padding: themeContract.space, + paddingTop: themeContract.space, + paddingBottom: themeContract.space, + paddingLeft: themeContract.space, + paddingRight: themeContract.space, + margin: themeContract.space, + marginTop: themeContract.space, + marginBottom: themeContract.space, + marginLeft: themeContract.space, + marginRight: themeContract.space, + + // Sizing + width: { + auto: "auto", + full: "100%", + screen: "100vw", + min: "min-content", + max: "max-content", + fit: "fit-content", + }, + height: { + auto: "auto", + full: "100%", + screen: "100vh", + min: "min-content", + max: "max-content", + fit: "fit-content", + }, + minWidth: { + 0: "0", + full: "100%", + min: "min-content", + max: "max-content", + fit: "fit-content", + }, + minHeight: { + 0: "0", + full: "100%", + screen: "100vh", + }, + maxWidth: { + none: "none", + full: "100%", + min: "min-content", + max: "max-content", + fit: "fit-content", + }, + maxHeight: { + none: "none", + full: "100%", + screen: "100vh", + }, + + // Position + position: ["static", "relative", "absolute", "fixed", "sticky"], + top: themeContract.space, + bottom: themeContract.space, + left: themeContract.space, + right: themeContract.space, + inset: themeContract.space, + + // Border radius + borderRadius: themeContract.radii, + borderTopLeftRadius: themeContract.radii, + borderTopRightRadius: themeContract.radii, + borderBottomLeftRadius: themeContract.radii, + borderBottomRightRadius: themeContract.radii, + + // Text + fontSize: themeContract.typography.fontSize, + fontWeight: themeContract.typography.fontWeight, + lineHeight: themeContract.typography.lineHeight, + textAlign: ["left", "center", "right", "justify"], + + // Overflow + overflow: ["visible", "hidden", "scroll", "auto"], + overflowX: ["visible", "hidden", "scroll", "auto"], + overflowY: ["visible", "hidden", "scroll", "auto"], + + // Z-index + zIndex: themeContract.zIndex, + + // Cursor + cursor: ["auto", "pointer", "not-allowed", "grab", "grabbing"], + + // Pointer events + pointerEvents: ["auto", "none"], + + // User select + userSelect: ["auto", "none", "text", "all"], + }, +}); + +/** + * Color properties (non-responsive) + */ +const colorProperties = defineProperties({ + properties: { + color: { + primary: themeContract.colors.text.primary, + secondary: themeContract.colors.text.secondary, + muted: themeContract.colors.text.muted, + }, + backgroundColor: { + transparent: "transparent", + primary: themeContract.colors.background.primary, + secondary: themeContract.colors.background.secondary, + accent: themeContract.colors.background.accent, + documentPrimary: themeContract.colors.document.primary, + documentSecondary: themeContract.colors.document.secondary, + documentAccent: themeContract.colors.document.accent, + memoryPrimary: themeContract.colors.memory.primary, + memorySecondary: themeContract.colors.memory.secondary, + memoryAccent: themeContract.colors.memory.accent, + }, + borderColor: { + transparent: "transparent", + documentBorder: themeContract.colors.document.border, + memoryBorder: themeContract.colors.memory.border, + }, + }, +}); + +/** + * Border properties + */ +const borderProperties = defineProperties({ + properties: { + borderWidth: { + 0: "0", + 1: "1px", + 2: "2px", + 4: "4px", + }, + borderStyle: ["none", "solid", "dashed", "dotted"], + }, +}); + +/** + * Opacity properties + */ +const opacityProperties = defineProperties({ + properties: { + opacity: { + 0: "0", + 10: "0.1", + 20: "0.2", + 30: "0.3", + 40: "0.4", + 50: "0.5", + 60: "0.6", + 70: "0.7", + 80: "0.8", + 90: "0.9", + 100: "1", + }, + }, +}); + +/** + * Combined sprinkles system + * Provides Tailwind-like utility classes with full type safety + */ +export const sprinkles = createSprinkles( + responsiveProperties, + colorProperties, + borderProperties, + opacityProperties, +); + +export type Sprinkles = Parameters<typeof sprinkles>[0]; diff --git a/packages/memory-graph/src/styles/theme.css.ts b/packages/memory-graph/src/styles/theme.css.ts new file mode 100644 index 00000000..bf08e3eb --- /dev/null +++ b/packages/memory-graph/src/styles/theme.css.ts @@ -0,0 +1,245 @@ +import { createTheme, createThemeContract } from "@vanilla-extract/css"; + +/** + * Theme contract defines the structure of the design system. + * Consumers can provide custom themes that match this contract. + */ +export const themeContract = createThemeContract({ + colors: { + // Background colors + background: { + primary: null, + secondary: null, + accent: null, + }, + // Document node colors + document: { + primary: null, + secondary: null, + accent: null, + border: null, + glow: null, + }, + // Memory node colors + memory: { + primary: null, + secondary: null, + accent: null, + border: null, + glow: null, + }, + // Connection strengths + connection: { + weak: null, + memory: null, + medium: null, + strong: null, + }, + // Text colors + text: { + primary: null, + secondary: null, + muted: null, + }, + // Accent colors + accent: { + primary: null, + secondary: null, + glow: null, + amber: null, + emerald: null, + }, + // Status indicators + status: { + forgotten: null, + expiring: null, + new: null, + }, + // Relation types + relations: { + updates: null, + extends: null, + derives: null, + }, + }, + space: { + 0: null, + 1: null, + 2: null, + 3: null, + 4: null, + 5: null, + 6: null, + 8: null, + 10: null, + 12: null, + 16: null, + 20: null, + 24: null, + 32: null, + 40: null, + 48: null, + 64: null, + }, + radii: { + none: null, + sm: null, + md: null, + lg: null, + xl: null, + "2xl": null, + full: null, + }, + typography: { + fontSize: { + xs: null, + sm: null, + base: null, + lg: null, + xl: null, + "2xl": null, + "3xl": null, + }, + fontWeight: { + normal: null, + medium: null, + semibold: null, + bold: null, + }, + lineHeight: { + tight: null, + normal: null, + relaxed: null, + }, + }, + transitions: { + fast: null, + normal: null, + slow: null, + }, + zIndex: { + base: null, + dropdown: null, + overlay: null, + modal: null, + tooltip: null, + }, +}); + +/** + * Default theme implementation based on the original constants.ts colors + * This provides the glass-morphism dark theme used throughout the app. + */ +export const defaultTheme = createTheme(themeContract, { + colors: { + background: { + primary: "#0f1419", // Deep dark blue-gray + secondary: "#1a1f29", // Slightly lighter + accent: "#252a35", // Card backgrounds + }, + document: { + primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white + secondary: "rgba(255, 255, 255, 0.12)", // More visible + accent: "rgba(255, 255, 255, 0.18)", // Hover state + border: "rgba(255, 255, 255, 0.25)", // Sharp borders + glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction + }, + memory: { + primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue + secondary: "rgba(147, 197, 253, 0.16)", // More visible + accent: "rgba(147, 197, 253, 0.24)", // Hover state + border: "rgba(147, 197, 253, 0.35)", // Sharp borders + glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction + }, + connection: { + weak: "rgba(148, 163, 184, 0)", // Very subtle + memory: "rgba(148, 163, 184, 0.3)", // Very subtle + medium: "rgba(148, 163, 184, 0.125)", // Medium visibility + strong: "rgba(148, 163, 184, 0.4)", // Strong connection + }, + text: { + primary: "#ffffff", // Pure white + secondary: "#e2e8f0", // Light gray + muted: "#94a3b8", // Medium gray + }, + accent: { + primary: "rgba(59, 130, 246, 0.7)", // Clean blue + secondary: "rgba(99, 102, 241, 0.6)", // Clean purple + glow: "rgba(147, 197, 253, 0.6)", // Subtle glow + amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring + emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new + }, + status: { + forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten + expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon + new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories + }, + relations: { + updates: "rgba(147, 77, 253, 0.5)", // purple + extends: "rgba(16, 185, 129, 0.5)", // green + derives: "rgba(147, 197, 253, 0.5)", // blue + }, + }, + space: { + 0: "0", + 1: "0.25rem", // 4px + 2: "0.5rem", // 8px + 3: "0.75rem", // 12px + 4: "1rem", // 16px + 5: "1.25rem", // 20px + 6: "1.5rem", // 24px + 8: "2rem", // 32px + 10: "2.5rem", // 40px + 12: "3rem", // 48px + 16: "4rem", // 64px + 20: "5rem", // 80px + 24: "6rem", // 96px + 32: "8rem", // 128px + 40: "10rem", // 160px + 48: "12rem", // 192px + 64: "16rem", // 256px + }, + radii: { + none: "0", + sm: "0.125rem", // 2px + md: "0.375rem", // 6px + lg: "0.5rem", // 8px + xl: "0.75rem", // 12px + "2xl": "1rem", // 16px + full: "9999px", + }, + typography: { + fontSize: { + xs: "0.75rem", // 12px + sm: "0.875rem", // 14px + base: "1rem", // 16px + lg: "1.125rem", // 18px + xl: "1.25rem", // 20px + "2xl": "1.5rem", // 24px + "3xl": "1.875rem", // 30px + }, + fontWeight: { + normal: "400", + medium: "500", + semibold: "600", + bold: "700", + }, + lineHeight: { + tight: "1.25", + normal: "1.5", + relaxed: "1.75", + }, + }, + transitions: { + fast: "150ms ease-in-out", + normal: "200ms ease-in-out", + slow: "300ms ease-in-out", + }, + zIndex: { + base: "0", + dropdown: "10", + overlay: "20", + modal: "30", + tooltip: "40", + }, +}); diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts new file mode 100644 index 00000000..796c8d01 --- /dev/null +++ b/packages/memory-graph/src/types.ts @@ -0,0 +1,120 @@ +import type { DocumentsResponse, DocumentWithMemories, MemoryEntry } from "./api-types"; + +// Re-export for convenience +export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }; + +export interface GraphNode { + id: string; + type: "document" | "memory"; + x: number; + y: number; + data: DocumentWithMemories | MemoryEntry; + size: number; + color: string; + isHovered: boolean; + isDragging: boolean; +} + +export type MemoryRelation = "updates" | "extends" | "derives"; + +export interface GraphEdge { + id: string; + source: string; + target: string; + similarity: number; + visualProps: { + opacity: number; + thickness: number; + glow: number; + pulseDuration: number; + }; + color: string; + edgeType: "doc-memory" | "doc-doc" | "version"; + relationType?: MemoryRelation; +} + +export interface SpacesDropdownProps { + selectedSpace: string; + availableSpaces: string[]; + spaceMemoryCounts: Record<string, number>; + onSpaceChange: (space: string) => void; +} + +export interface NodeDetailPanelProps { + node: GraphNode | null; + onClose: () => void; + variant?: "console" | "consumer"; +} + +export interface GraphCanvasProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + panX: number; + panY: number; + zoom: number; + width: number; + height: number; + onNodeHover: (nodeId: string | null) => void; + onNodeClick: (nodeId: string) => void; + onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void; + onNodeDragMove: (e: React.MouseEvent) => void; + onNodeDragEnd: () => void; + onPanStart: (e: React.MouseEvent) => void; + onPanMove: (e: React.MouseEvent) => void; + onPanEnd: () => void; + onWheel: (e: React.WheelEvent) => void; + onDoubleClick: (e: React.MouseEvent) => void; + onTouchStart?: (e: React.TouchEvent) => void; + onTouchMove?: (e: React.TouchEvent) => void; + onTouchEnd?: (e: React.TouchEvent) => void; + draggingNodeId: string | null; + // Optional list of document IDs (customId or internal id) to highlight + highlightDocumentIds?: string[]; +} + +export interface MemoryGraphProps { + children?: React.ReactNode; + 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) + highlightDocumentIds?: string[]; + // Whether highlights are currently visible (e.g., chat open) + highlightsVisible?: boolean; + // Pixels occluded on the right side of the viewport (e.g., chat panel) + occludedRightPx?: number; + // Whether to auto-load more documents based on viewport visibility + autoLoadOnViewport?: boolean; + // Theme class name to apply + themeClassName?: string; +} + +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"; +} + +export interface ControlsProps { + onZoomIn: () => void; + onZoomOut: () => void; + onResetView: () => void; + variant?: "console" | "consumer"; +} diff --git a/packages/memory-graph/src/ui/badge.css.ts b/packages/memory-graph/src/ui/badge.css.ts new file mode 100644 index 00000000..1af96c1d --- /dev/null +++ b/packages/memory-graph/src/ui/badge.css.ts @@ -0,0 +1,119 @@ +import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"; +import { style, globalStyle } from "@vanilla-extract/css"; +import { themeContract } from "../styles/theme.css"; + +/** + * Base styles for SVG icons inside badges + */ +export const badgeIcon = style({ + width: "0.75rem", + height: "0.75rem", + pointerEvents: "none", +}); + +/** + * Badge recipe with variants + * Replaces CVA-based badge variants with vanilla-extract recipes + */ +const badgeBase = style({ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + borderRadius: themeContract.radii.md, + border: "1px solid", + paddingLeft: themeContract.space[2], + paddingRight: themeContract.space[2], + paddingTop: "0.125rem", + paddingBottom: "0.125rem", + fontSize: themeContract.typography.fontSize.xs, + fontWeight: themeContract.typography.fontWeight.medium, + width: "fit-content", + whiteSpace: "nowrap", + flexShrink: 0, + gap: themeContract.space[1], + transition: "color 200ms ease-in-out, box-shadow 200ms ease-in-out", + overflow: "hidden", + + selectors: { + "&:focus-visible": { + borderColor: themeContract.colors.accent.primary, + boxShadow: `0 0 0 2px ${themeContract.colors.accent.primary}33`, + }, + "&[aria-invalid='true']": { + boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`, + borderColor: themeContract.colors.status.forgotten, + }, + }, +}); + +// Global style for SVG children +globalStyle(`${badgeBase} > svg`, { + width: "0.75rem", + height: "0.75rem", + pointerEvents: "none", +}); + +export const badge = recipe({ + base: badgeBase, + + variants: { + variant: { + default: { + borderColor: "transparent", + backgroundColor: themeContract.colors.accent.primary, + color: themeContract.colors.text.primary, + + selectors: { + "a&:hover": { + opacity: 0.9, + }, + }, + }, + + secondary: { + borderColor: "transparent", + backgroundColor: themeContract.colors.background.secondary, + color: themeContract.colors.text.secondary, + + selectors: { + "a&:hover": { + backgroundColor: themeContract.colors.background.accent, + }, + }, + }, + + destructive: { + borderColor: "transparent", + backgroundColor: themeContract.colors.status.forgotten, + color: themeContract.colors.text.primary, + + selectors: { + "a&:hover": { + opacity: 0.9, + }, + "&:focus-visible": { + boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`, + }, + }, + }, + + outline: { + borderColor: themeContract.colors.document.border, + backgroundColor: "transparent", + color: themeContract.colors.text.primary, + + selectors: { + "a&:hover": { + backgroundColor: themeContract.colors.document.primary, + }, + }, + }, + }, + }, + + defaultVariants: { + variant: "default", + }, +}); + +export type BadgeVariants = RecipeVariants<typeof badge>; diff --git a/packages/memory-graph/src/ui/badge.tsx b/packages/memory-graph/src/ui/badge.tsx new file mode 100644 index 00000000..0708888f --- /dev/null +++ b/packages/memory-graph/src/ui/badge.tsx @@ -0,0 +1,27 @@ +import { Slot } from "@radix-ui/react-slot"; +import type * as React from "react"; +import { badge, type BadgeVariants } from "./badge.css"; + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + BadgeVariants & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + const combinedClassName = className + ? `${badge({ variant })} ${className}` + : badge({ variant }); + + return ( + <Comp + className={combinedClassName} + data-slot="badge" + {...props} + /> + ); +} + +export { Badge, badge as badgeVariants }; diff --git a/packages/memory-graph/src/ui/button.css.ts b/packages/memory-graph/src/ui/button.css.ts new file mode 100644 index 00000000..ad9cce8c --- /dev/null +++ b/packages/memory-graph/src/ui/button.css.ts @@ -0,0 +1,210 @@ +import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"; +import { style } from "@vanilla-extract/css"; +import { themeContract } from "../styles/theme.css"; + +/** + * Base styles for SVG icons inside buttons + */ +export const buttonIcon = style({ + pointerEvents: "none", + flexShrink: 0, + selectors: { + "&:not([class*='size-'])": { + width: "1rem", + height: "1rem", + }, + }, +}); + +/** + * Button recipe with variants + * Replaces CVA-based button variants with vanilla-extract recipes + */ +export const button = recipe({ + base: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: themeContract.space[2], + whiteSpace: "nowrap", + borderRadius: themeContract.radii.md, + fontSize: themeContract.typography.fontSize.sm, + fontWeight: themeContract.typography.fontWeight.medium, + transition: themeContract.transitions.normal, + flexShrink: 0, + outline: "none", + border: "1px solid transparent", + cursor: "pointer", + + // SVG sizing + selectors: { + [`&:has(${buttonIcon})`]: { + // Buttons with icons get adjusted padding + }, + "&:disabled": { + pointerEvents: "none", + opacity: 0.5, + }, + "&:focus-visible": { + borderColor: themeContract.colors.accent.primary, + boxShadow: `0 0 0 2px ${themeContract.colors.accent.primary}33`, + }, + "&[aria-invalid='true']": { + boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}`, + borderColor: themeContract.colors.status.forgotten, + }, + }, + }, + + variants: { + variant: { + default: { + backgroundColor: themeContract.colors.accent.primary, + color: themeContract.colors.text.primary, + boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", + + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: themeContract.colors.accent.secondary, + }, + }, + }, + + destructive: { + backgroundColor: themeContract.colors.status.forgotten, + color: themeContract.colors.text.primary, + boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", + + selectors: { + "&:hover:not(:disabled)": { + opacity: 0.9, + }, + "&:focus-visible": { + boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`, + }, + }, + }, + + outline: { + backgroundColor: themeContract.colors.background.primary, + borderColor: themeContract.colors.document.border, + color: themeContract.colors.text.primary, + boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", + + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: themeContract.colors.document.primary, + }, + }, + }, + + secondary: { + backgroundColor: themeContract.colors.background.secondary, + color: themeContract.colors.text.secondary, + boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", + + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: themeContract.colors.background.accent, + }, + }, + }, + + ghost: { + backgroundColor: "transparent", + color: themeContract.colors.text.primary, + + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: themeContract.colors.document.primary, + }, + }, + }, + + link: { + backgroundColor: "transparent", + color: themeContract.colors.accent.primary, + textDecoration: "underline", + textUnderlineOffset: "4px", + + selectors: { + "&:hover:not(:disabled)": { + textDecoration: "underline", + }, + }, + }, + + settingsNav: { + cursor: "pointer", + borderRadius: themeContract.radii.sm, + backgroundColor: "transparent", + color: themeContract.colors.text.primary, + }, + }, + + size: { + default: { + height: "36px", + paddingLeft: themeContract.space[4], + paddingRight: themeContract.space[4], + paddingTop: themeContract.space[2], + paddingBottom: themeContract.space[2], + + selectors: { + "&:has(svg)": { + paddingLeft: themeContract.space[3], + paddingRight: themeContract.space[3], + }, + }, + }, + + sm: { + height: "32px", + borderRadius: themeContract.radii.md, + gap: themeContract.space[1], + paddingLeft: themeContract.space[3], + paddingRight: themeContract.space[3], + + selectors: { + "&:has(svg)": { + paddingLeft: themeContract.space[2], + paddingRight: themeContract.space[2], + }, + }, + }, + + lg: { + height: "40px", + borderRadius: themeContract.radii.md, + paddingLeft: themeContract.space[6], + paddingRight: themeContract.space[6], + + selectors: { + "&:has(svg)": { + paddingLeft: themeContract.space[4], + paddingRight: themeContract.space[4], + }, + }, + }, + + icon: { + width: "36px", + height: "36px", + padding: 0, + }, + + settingsNav: { + height: "32px", + gap: 0, + padding: 0, + }, + }, + }, + + defaultVariants: { + variant: "default", + size: "default", + }, +}); + +export type ButtonVariants = RecipeVariants<typeof button>; diff --git a/packages/memory-graph/src/ui/button.tsx b/packages/memory-graph/src/ui/button.tsx new file mode 100644 index 00000000..031f2cc8 --- /dev/null +++ b/packages/memory-graph/src/ui/button.tsx @@ -0,0 +1,30 @@ +import { Slot } from "@radix-ui/react-slot"; +import type * as React from "react"; +import { button, type ButtonVariants } from "./button.css"; + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + ButtonVariants & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + const combinedClassName = className + ? `${button({ variant, size })} ${className}` + : button({ variant, size }); + + return ( + <Comp + className={combinedClassName} + data-slot="button" + {...props} + /> + ); +} + +export { Button, button as buttonVariants }; diff --git a/packages/memory-graph/src/ui/collapsible.tsx b/packages/memory-graph/src/ui/collapsible.tsx new file mode 100644 index 00000000..0551ffdd --- /dev/null +++ b/packages/memory-graph/src/ui/collapsible.tsx @@ -0,0 +1,33 @@ +"use client"; + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +function Collapsible({ + ...props +}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { + return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) { + return ( + <CollapsiblePrimitive.CollapsibleTrigger + data-slot="collapsible-trigger" + {...props} + /> + ); +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) { + return ( + <CollapsiblePrimitive.CollapsibleContent + data-slot="collapsible-content" + {...props} + /> + ); +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/packages/memory-graph/src/ui/glass-effect.css.ts b/packages/memory-graph/src/ui/glass-effect.css.ts new file mode 100644 index 00000000..16e0fcdc --- /dev/null +++ b/packages/memory-graph/src/ui/glass-effect.css.ts @@ -0,0 +1,58 @@ +import { style } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; +import { themeContract } from "../styles/theme.css"; + +/** + * Glass menu effect container + */ +export const glassMenuContainer = style({ + position: "absolute", + inset: 0, +}); + +/** + * Glass menu effect with customizable border radius + */ +export const glassMenuEffect = recipe({ + base: { + position: "absolute", + inset: 0, + backdropFilter: "blur(12px)", + WebkitBackdropFilter: "blur(12px)", + background: "rgba(255, 255, 255, 0.05)", + border: `1px solid ${themeContract.colors.document.border}`, + }, + + variants: { + rounded: { + none: { + borderRadius: themeContract.radii.none, + }, + sm: { + borderRadius: themeContract.radii.sm, + }, + md: { + borderRadius: themeContract.radii.md, + }, + lg: { + borderRadius: themeContract.radii.lg, + }, + xl: { + borderRadius: themeContract.radii.xl, + }, + "2xl": { + borderRadius: themeContract.radii["2xl"], + }, + "3xl": { + borderRadius: "1.5rem", // Tailwind's rounded-3xl + }, + full: { + borderRadius: themeContract.radii.full, + }, + }, + }, + + defaultVariants: { + rounded: "3xl", + }, +}); diff --git a/packages/memory-graph/src/ui/glass-effect.tsx b/packages/memory-graph/src/ui/glass-effect.tsx new file mode 100644 index 00000000..e1908f52 --- /dev/null +++ b/packages/memory-graph/src/ui/glass-effect.tsx @@ -0,0 +1,21 @@ +import { + glassMenuContainer, + glassMenuEffect, +} from "./glass-effect.css"; + +interface GlassMenuEffectProps { + rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"; + className?: string; +} + +export function GlassMenuEffect({ + rounded = "3xl", + className = "", +}: GlassMenuEffectProps) { + return ( + <div className={`${glassMenuContainer} ${className}`}> + {/* Frosted glass effect with translucent border */} + <div className={glassMenuEffect({ rounded })} /> + </div> + ); +} diff --git a/packages/memory-graph/src/ui/heading.css.ts b/packages/memory-graph/src/ui/heading.css.ts new file mode 100644 index 00000000..128d97a6 --- /dev/null +++ b/packages/memory-graph/src/ui/heading.css.ts @@ -0,0 +1,24 @@ +import { style } from "@vanilla-extract/css"; +import { themeContract } from "../styles/theme.css"; + +/** + * Responsive heading style with bold weight + */ +export const headingH3Bold = style({ + fontSize: "0.625rem", // 10px + fontWeight: themeContract.typography.fontWeight.bold, + lineHeight: "28px", + letterSpacing: "-0.4px", + + "@media": { + "screen and (min-width: 640px)": { + fontSize: themeContract.typography.fontSize.xs, // 12px + }, + "screen and (min-width: 768px)": { + fontSize: themeContract.typography.fontSize.sm, // 14px + }, + "screen and (min-width: 1024px)": { + fontSize: themeContract.typography.fontSize.base, // 16px + }, + }, +}); diff --git a/packages/memory-graph/src/ui/heading.tsx b/packages/memory-graph/src/ui/heading.tsx new file mode 100644 index 00000000..65e8abc8 --- /dev/null +++ b/packages/memory-graph/src/ui/heading.tsx @@ -0,0 +1,18 @@ +import { Root } from "@radix-ui/react-slot"; +import { headingH3Bold } from "./heading.css"; + +export function HeadingH3Bold({ + className, + asChild, + ...props +}: React.ComponentProps<"h3"> & { asChild?: boolean }) { + const Comp = asChild ? Root : "h3"; + + const combinedClassName = className + ? `${headingH3Bold} ${className}` + : headingH3Bold; + + return ( + <Comp className={combinedClassName} {...props} /> + ); +} diff --git a/packages/memory-graph/tsconfig.json b/packages/memory-graph/tsconfig.json new file mode 100644 index 00000000..c8878527 --- /dev/null +++ b/packages/memory-graph/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "@total-typescript/tsconfig/bundler/dom/library", + "compilerOptions": { + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"] +} diff --git a/packages/memory-graph/vite.config.ts b/packages/memory-graph/vite.config.ts new file mode 100644 index 00000000..3098067f --- /dev/null +++ b/packages/memory-graph/vite.config.ts @@ -0,0 +1,56 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' +import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; +import { libInjectCss } from 'vite-plugin-lib-inject-css'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react(), vanillaExtractPlugin(), libInjectCss()], + build: { + lib: { + entry: resolve(__dirname, 'src/index.tsx'), + name: 'MemoryGraph', + formats: ['es', 'cjs'], + fileName: (format) => { + if (format === 'es') return 'memory-graph.js' + if (format === 'cjs') return 'memory-graph.cjs' + return 'memory-graph.js' + } + }, + rollupOptions: { + // Externalize only peer dependencies (React) + external: ['react', 'react-dom', 'react/jsx-runtime'], + output: { + // Provide global variables for UMD build (if needed later) + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'react/jsx-runtime': 'react/jsx-runtime' + }, + // Preserve CSS as separate file + assetFileNames: (assetInfo) => { + // Vanilla-extract generates index.css, rename to memory-graph.css + if (assetInfo.name === 'index.css' || assetInfo.name === 'style.css') { + return 'memory-graph.css' + } + return assetInfo.name || 'asset' + }, + // Don't preserve modules - bundle everything except externals + preserveModules: false, + } + }, + // Ensure CSS is extracted + cssCodeSplit: false, + // Generate sourcemaps for debugging + sourcemap: true, + // Optimize deps + minify: 'esbuild', + target: 'esnext' + }, + resolve: { + alias: { + '@': resolve(__dirname, './src') + } + } +}) diff --git a/packages/ui/memory-graph/legend.tsx b/packages/ui/memory-graph/legend.tsx index 81c634f3..db2495cc 100644 --- a/packages/ui/memory-graph/legend.tsx +++ b/packages/ui/memory-graph/legend.tsx @@ -39,7 +39,6 @@ interface ExtendedLegendProps extends LegendProps { nodes?: GraphNode[]; edges?: GraphEdge[]; isLoading?: boolean; - isExperimental?: boolean; } export const Legend = memo(function Legend({ @@ -48,20 +47,11 @@ export const Legend = memo(function Legend({ nodes = [], edges = [], isLoading = false, - isExperimental = false, }: ExtendedLegendProps) { const isMobile = useIsMobile(); const [isExpanded, setIsExpanded] = useState(true); const [isInitialized, setIsInitialized] = useState(false); - const relationData = isExperimental - ? [ - ["updates", colors.relations.updates], - ["extends", colors.relations.extends], - ["derives", colors.relations.derives], - ] - : [["updates", colors.relations.updates]]; - // Load saved preference on client side useEffect(() => { if (!isInitialized) { @@ -271,14 +261,11 @@ export const Legend = memo(function Legend({ Relations </div> <div className="space-y-1.5"> - {(isExperimental - ? [ - ["updates", colors.relations.updates], - ["extends", colors.relations.extends], - ["derives", colors.relations.derives], - ] - : [["updates", colors.relations.updates]] - ).map(([label, color]) => ( + {[ + ["updates", colors.relations.updates], + ["extends", colors.relations.extends], + ["derives", colors.relations.derives], + ].map(([label, color]) => ( <div className="flex items-center gap-2" key={label}> <div className="w-4 h-0 border-t-2 flex-shrink-0" diff --git a/packages/ui/memory-graph/memory-graph.tsx b/packages/ui/memory-graph/memory-graph.tsx index 69c544f3..8c1ad3c2 100644 --- a/packages/ui/memory-graph/memory-graph.tsx +++ b/packages/ui/memory-graph/memory-graph.tsx @@ -24,15 +24,18 @@ export const MemoryGraph = ({ totalLoaded, hasMore, loadMoreDocuments, - showSpacesSelector = true, + showSpacesSelector, variant = "console", legendId, highlightDocumentIds = [], highlightsVisible = true, occludedRightPx = 0, autoLoadOnViewport = true, - isExperimental = false, }: MemoryGraphProps) => { + // Derive showSpacesSelector from variant if not explicitly provided + // console variant shows spaces selector, consumer variant hides it + const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console"); + const [selectedSpace, setSelectedSpace] = useState<string>("all"); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const containerRef = useRef<HTMLDivElement>(null); @@ -356,7 +359,7 @@ export const MemoryGraph = ({ style={{ backgroundColor: colors.background.primary }} > {/* Spaces selector - only shown for console */} - {showSpacesSelector && availableSpaces.length > 0 && ( + {finalShowSpacesSelector && availableSpaces.length > 0 && ( <div className="absolute top-4 left-4 z-10"> <SpacesDropdown availableSpaces={availableSpaces} @@ -382,7 +385,6 @@ export const MemoryGraph = ({ isLoading={isLoading} nodes={nodes} variant={variant} - isExperimental={isExperimental} /> {/* Node detail panel */} diff --git a/packages/ui/memory-graph/types.ts b/packages/ui/memory-graph/types.ts index 4692d2c0..a939c619 100644 --- a/packages/ui/memory-graph/types.ts +++ b/packages/ui/memory-graph/types.ts @@ -74,7 +74,6 @@ export interface GraphCanvasProps { draggingNodeId: string | null; // Optional list of document IDs (customId or internal id) to highlight highlightDocumentIds?: string[]; - isExperimental?: boolean; } export interface MemoryGraphProps { @@ -98,7 +97,6 @@ export interface MemoryGraphProps { occludedRightPx?: number; // Whether to auto-load more documents based on viewport visibility autoLoadOnViewport?: boolean; - isExperimental?: boolean; } export interface LegendProps { |