mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
package the graph (#563)
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 
This commit is contained in:
parent
d8e7c381a0
commit
5e24eb66c3
68 changed files with 7527 additions and 445 deletions
|
|
@ -243,4 +243,4 @@ export function GraphDialog() {
|
|||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"@google/generative-ai": "^0.24.1",
|
||||
"@hono/zod-validator": "^0.7.1",
|
||||
"@scalar/hono-api-reference": "^0.9.11",
|
||||
"@vanilla-extract/recipes": "^0.5.7",
|
||||
"ai": "^5.0.59",
|
||||
"alchemy": "^0.55.2",
|
||||
"atmn": "^0.0.16",
|
||||
|
|
|
|||
24
packages/memory-graph-playground/.gitignore
vendored
Normal file
24
packages/memory-graph-playground/.gitignore
vendored
Normal file
|
|
@ -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?
|
||||
47
packages/memory-graph-playground/README.md
Normal file
47
packages/memory-graph-playground/README.md
Normal file
|
|
@ -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
|
||||
23
packages/memory-graph-playground/eslint.config.js
Normal file
23
packages/memory-graph-playground/eslint.config.js
Normal file
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
packages/memory-graph-playground/index.html
Normal file
13
packages/memory-graph-playground/index.html
Normal file
|
|
@ -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>
|
||||
31
packages/memory-graph-playground/package.json
Normal file
31
packages/memory-graph-playground/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
1
packages/memory-graph-playground/public/vite.svg
Normal file
1
packages/memory-graph-playground/public/vite.svg
Normal file
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
119
packages/memory-graph-playground/src/App.css
Normal file
119
packages/memory-graph-playground/src/App.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
71
packages/memory-graph-playground/src/App.tsx
Normal file
71
packages/memory-graph-playground/src/App.tsx
Normal file
|
|
@ -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
|
||||
1
packages/memory-graph-playground/src/assets/react.svg
Normal file
1
packages/memory-graph-playground/src/assets/react.svg
Normal file
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 4 KiB |
31
packages/memory-graph-playground/src/index.css
Normal file
31
packages/memory-graph-playground/src/index.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
11
packages/memory-graph-playground/src/main.tsx
Normal file
11
packages/memory-graph-playground/src/main.tsx
Normal file
|
|
@ -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>,
|
||||
)
|
||||
28
packages/memory-graph-playground/tsconfig.app.json
Normal file
28
packages/memory-graph-playground/tsconfig.app.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
7
packages/memory-graph-playground/tsconfig.json
Normal file
7
packages/memory-graph-playground/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
packages/memory-graph-playground/tsconfig.node.json
Normal file
26
packages/memory-graph-playground/tsconfig.node.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
7
packages/memory-graph-playground/vite.config.ts
Normal file
7
packages/memory-graph-playground/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
33
packages/memory-graph/.gitignore
vendored
Normal file
33
packages/memory-graph/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
42
packages/memory-graph/.npmignore
Normal file
42
packages/memory-graph/.npmignore
Normal file
|
|
@ -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
|
||||
224
packages/memory-graph/README.md
Normal file
224
packages/memory-graph/README.md
Normal file
|
|
@ -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: support@supermemory.ai
|
||||
- 🐛 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
|
||||
81
packages/memory-graph/package.json
Normal file
81
packages/memory-graph/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
79
packages/memory-graph/src/api-types.ts
Normal file
79
packages/memory-graph/src/api-types.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Standalone TypeScript types for Memory Graph
|
||||
// These mirror the API response types from @repo/validation/api
|
||||
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
customId?: string | null;
|
||||
documentId: string;
|
||||
content: string | null;
|
||||
summary?: string | null;
|
||||
title?: string | null;
|
||||
url?: string | null;
|
||||
type?: string | null;
|
||||
metadata?: Record<string, string | number | boolean> | null;
|
||||
embedding?: number[] | null;
|
||||
embeddingModel?: string | null;
|
||||
tokenCount?: number | null;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
// Fields from join relationship
|
||||
sourceAddedAt?: Date | null;
|
||||
sourceRelevanceScore?: number | null;
|
||||
sourceMetadata?: Record<string, unknown> | null;
|
||||
spaceContainerTag?: string | null;
|
||||
// Version chain fields
|
||||
updatesMemoryId?: string | null;
|
||||
nextVersionId?: string | null;
|
||||
relation?: "updates" | "extends" | "derives" | null;
|
||||
// Memory status fields
|
||||
isForgotten?: boolean;
|
||||
forgetAfter?: Date | string | null;
|
||||
isLatest?: boolean;
|
||||
// Space/container fields
|
||||
spaceId?: string | null;
|
||||
// Legacy fields
|
||||
memory?: string | null;
|
||||
memoryRelations?: Array<{
|
||||
relationType: "updates" | "extends" | "derives";
|
||||
targetMemoryId: string;
|
||||
}> | null;
|
||||
parentMemoryId?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentWithMemories {
|
||||
id: string;
|
||||
customId?: string | null;
|
||||
contentHash: string | null;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
connectionId?: string | null;
|
||||
title?: string | null;
|
||||
content?: string | null;
|
||||
summary?: string | null;
|
||||
url?: string | null;
|
||||
source?: string | null;
|
||||
type?: string | null;
|
||||
status: "pending" | "processing" | "done" | "failed";
|
||||
metadata?: Record<string, string | number | boolean> | null;
|
||||
processingMetadata?: Record<string, unknown> | null;
|
||||
raw?: string | null;
|
||||
tokenCount?: number | null;
|
||||
wordCount?: number | null;
|
||||
chunkCount?: number | null;
|
||||
averageChunkSize?: number | null;
|
||||
summaryEmbedding?: number[] | null;
|
||||
summaryEmbeddingModel?: string | null;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
memoryEntries: MemoryEntry[];
|
||||
}
|
||||
|
||||
export interface DocumentsResponse {
|
||||
documents: DocumentWithMemories[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
208
packages/memory-graph/src/assets/icons.tsx
Normal file
208
packages/memory-graph/src/assets/icons.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
10
packages/memory-graph/src/components/canvas-common.css.ts
Normal file
10
packages/memory-graph/src/components/canvas-common.css.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
764
packages/memory-graph/src/components/graph-canvas.tsx
Normal file
764
packages/memory-graph/src/components/graph-canvas.tsx
Normal file
|
|
@ -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";
|
||||
345
packages/memory-graph/src/components/legend.css.ts
Normal file
345
packages/memory-graph/src/components/legend.css.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
275
packages/memory-graph/src/components/legend.tsx
Normal file
275
packages/memory-graph/src/components/legend.tsx
Normal file
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
});
|
||||
40
packages/memory-graph/src/components/loading-indicator.tsx
Normal file
40
packages/memory-graph/src/components/loading-indicator.tsx
Normal file
|
|
@ -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";
|
||||
198
packages/memory-graph/src/components/memory-graph-wrapper.tsx
Normal file
198
packages/memory-graph/src/components/memory-graph-wrapper.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
75
packages/memory-graph/src/components/memory-graph.css.ts
Normal file
75
packages/memory-graph/src/components/memory-graph.css.ts
Normal file
|
|
@ -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
|
||||
});
|
||||
448
packages/memory-graph/src/components/memory-graph.tsx
Normal file
448
packages/memory-graph/src/components/memory-graph.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
73
packages/memory-graph/src/components/navigation-controls.tsx
Normal file
73
packages/memory-graph/src/components/navigation-controls.tsx
Normal file
|
|
@ -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";
|
||||
170
packages/memory-graph/src/components/node-detail-panel.css.ts
Normal file
170
packages/memory-graph/src/components/node-detail-panel.css.ts
Normal file
|
|
@ -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",
|
||||
});
|
||||
266
packages/memory-graph/src/components/node-detail-panel.tsx
Normal file
266
packages/memory-graph/src/components/node-detail-panel.tsx
Normal file
|
|
@ -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";
|
||||
158
packages/memory-graph/src/components/spaces-dropdown.css.ts
Normal file
158
packages/memory-graph/src/components/spaces-dropdown.css.ts
Normal file
|
|
@ -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],
|
||||
});
|
||||
110
packages/memory-graph/src/components/spaces-dropdown.tsx
Normal file
110
packages/memory-graph/src/components/spaces-dropdown.tsx
Normal file
|
|
@ -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";
|
||||
100
packages/memory-graph/src/constants.ts
Normal file
100
packages/memory-graph/src/constants.ts
Normal file
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
113
packages/memory-graph/src/hooks/use-documents-query.ts
Normal file
113
packages/memory-graph/src/hooks/use-documents-query.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
308
packages/memory-graph/src/hooks/use-graph-data.ts
Normal file
308
packages/memory-graph/src/hooks/use-graph-data.ts
Normal file
|
|
@ -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]);
|
||||
}
|
||||
564
packages/memory-graph/src/hooks/use-graph-interactions.ts
Normal file
564
packages/memory-graph/src/hooks/use-graph-interactions.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
19
packages/memory-graph/src/hooks/use-mobile.ts
Normal file
19
packages/memory-graph/src/hooks/use-mobile.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
46
packages/memory-graph/src/index.tsx
Normal file
46
packages/memory-graph/src/index.tsx
Normal file
|
|
@ -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";
|
||||
213
packages/memory-graph/src/lib/api-client.ts
Normal file
213
packages/memory-graph/src/lib/api-client.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
115
packages/memory-graph/src/lib/similarity.ts
Normal file
115
packages/memory-graph/src/lib/similarity.ts
Normal file
|
|
@ -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}%)`
|
||||
}
|
||||
116
packages/memory-graph/src/styles/animations.css.ts
Normal file
116
packages/memory-graph/src/styles/animations.css.ts
Normal file
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
120
packages/memory-graph/src/styles/effects.css.ts
Normal file
120
packages/memory-graph/src/styles/effects.css.ts
Normal file
|
|
@ -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}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
71
packages/memory-graph/src/styles/global.css.ts
Normal file
71
packages/memory-graph/src/styles/global.css.ts
Normal file
|
|
@ -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",
|
||||
});
|
||||
20
packages/memory-graph/src/styles/index.ts
Normal file
20
packages/memory-graph/src/styles/index.ts
Normal file
|
|
@ -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";
|
||||
204
packages/memory-graph/src/styles/sprinkles.css.ts
Normal file
204
packages/memory-graph/src/styles/sprinkles.css.ts
Normal file
|
|
@ -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];
|
||||
245
packages/memory-graph/src/styles/theme.css.ts
Normal file
245
packages/memory-graph/src/styles/theme.css.ts
Normal file
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
120
packages/memory-graph/src/types.ts
Normal file
120
packages/memory-graph/src/types.ts
Normal file
|
|
@ -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";
|
||||
}
|
||||
119
packages/memory-graph/src/ui/badge.css.ts
Normal file
119
packages/memory-graph/src/ui/badge.css.ts
Normal file
|
|
@ -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>;
|
||||
27
packages/memory-graph/src/ui/badge.tsx
Normal file
27
packages/memory-graph/src/ui/badge.tsx
Normal file
|
|
@ -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 };
|
||||
210
packages/memory-graph/src/ui/button.css.ts
Normal file
210
packages/memory-graph/src/ui/button.css.ts
Normal file
|
|
@ -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>;
|
||||
30
packages/memory-graph/src/ui/button.tsx
Normal file
30
packages/memory-graph/src/ui/button.tsx
Normal file
|
|
@ -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 };
|
||||
33
packages/memory-graph/src/ui/collapsible.tsx
Normal file
33
packages/memory-graph/src/ui/collapsible.tsx
Normal file
|
|
@ -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 };
|
||||
58
packages/memory-graph/src/ui/glass-effect.css.ts
Normal file
58
packages/memory-graph/src/ui/glass-effect.css.ts
Normal file
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
21
packages/memory-graph/src/ui/glass-effect.tsx
Normal file
21
packages/memory-graph/src/ui/glass-effect.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
24
packages/memory-graph/src/ui/heading.css.ts
Normal file
24
packages/memory-graph/src/ui/heading.css.ts
Normal file
|
|
@ -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
|
||||
},
|
||||
},
|
||||
});
|
||||
18
packages/memory-graph/src/ui/heading.tsx
Normal file
18
packages/memory-graph/src/ui/heading.tsx
Normal file
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
26
packages/memory-graph/tsconfig.json
Normal file
26
packages/memory-graph/tsconfig.json
Normal file
|
|
@ -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.*"]
|
||||
}
|
||||
56
packages/memory-graph/vite.config.ts
Normal file
56
packages/memory-graph/vite.config.ts
Normal file
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue